Skip to main content

datafusion_datasource/file_stream/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! A generic stream over file format readers that can be used by
19//! any file format that read its files from start to end.
20//!
21//! Note: Most traits here need to be marked `Sync + Send` to be
22//! compliant with the `SendableRecordBatchStream` trait.
23
24mod builder;
25mod metrics;
26mod scan_state;
27pub(crate) mod work_source;
28
29use std::pin::Pin;
30use std::sync::Arc;
31use std::task::{Context, Poll};
32
33use crate::PartitionedFile;
34use crate::file_scan_config::FileScanConfig;
35use arrow::datatypes::SchemaRef;
36use datafusion_common::Result;
37use datafusion_execution::RecordBatchStream;
38use datafusion_physical_plan::metrics::{BaselineMetrics, ExecutionPlanMetricsSet};
39
40use arrow::record_batch::RecordBatch;
41
42use futures::Stream;
43use futures::future::BoxFuture;
44use futures::stream::BoxStream;
45
46use self::scan_state::{ScanAndReturn, ScanState};
47
48pub use builder::FileStreamBuilder;
49pub use metrics::{FileStreamMetrics, StartableTime};
50
51/// A stream that iterates record batch by record batch, file over file.
52pub struct FileStream {
53    /// The stream schema (file schema including partition columns and after
54    /// projection).
55    projected_schema: SchemaRef,
56    /// The stream state
57    state: FileStreamState,
58    /// runtime baseline metrics
59    baseline_metrics: BaselineMetrics,
60}
61
62impl FileStream {
63    /// Create a new `FileStream` using the give `FileOpener` to scan underlying files
64    #[deprecated(since = "54.0.0", note = "Use FileStreamBuilder instead")]
65    pub fn new(
66        config: &FileScanConfig,
67        partition: usize,
68        file_opener: Arc<dyn FileOpener>,
69        metrics: &ExecutionPlanMetricsSet,
70    ) -> Result<Self> {
71        FileStreamBuilder::new(config)
72            .with_partition(partition)
73            .with_file_opener(file_opener)
74            .with_metrics(metrics)
75            .build()
76    }
77
78    /// Specify the behavior when an error occurs opening or scanning a file
79    ///
80    /// If `OnError::Skip` the stream will skip files which encounter an error and continue
81    /// If `OnError:Fail` (default) the stream will fail and stop processing when an error occurs
82    pub fn with_on_error(mut self, on_error: OnError) -> Self {
83        match &mut self.state {
84            FileStreamState::Scan { scan_state } => scan_state.set_on_error(on_error),
85            FileStreamState::Error | FileStreamState::Done => {
86                // no effect as there are no more files to process
87            }
88        };
89        self
90    }
91
92    fn poll_inner(&mut self, cx: &mut Context<'_>) -> Poll<Option<Result<RecordBatch>>> {
93        loop {
94            match &mut self.state {
95                FileStreamState::Scan { scan_state: queue } => {
96                    let action = queue.poll_scan(cx);
97                    match action {
98                        ScanAndReturn::Continue => continue,
99                        ScanAndReturn::Done(result) => {
100                            self.state = FileStreamState::Done;
101                            return Poll::Ready(result);
102                        }
103                        ScanAndReturn::Error(err) => {
104                            self.state = FileStreamState::Error;
105                            return Poll::Ready(Some(Err(err)));
106                        }
107                        ScanAndReturn::Return(result) => return result,
108                    }
109                }
110                FileStreamState::Error | FileStreamState::Done => {
111                    return Poll::Ready(None);
112                }
113            }
114        }
115    }
116}
117
118impl Stream for FileStream {
119    type Item = Result<RecordBatch>;
120
121    fn poll_next(
122        mut self: Pin<&mut Self>,
123        cx: &mut Context<'_>,
124    ) -> Poll<Option<Self::Item>> {
125        let result = self.poll_inner(cx);
126        self.baseline_metrics.record_poll(result)
127    }
128}
129
130impl RecordBatchStream for FileStream {
131    fn schema(&self) -> SchemaRef {
132        Arc::clone(&self.projected_schema)
133    }
134}
135
136/// A fallible future that resolves to a stream of [`RecordBatch`]
137pub type FileOpenFuture =
138    BoxFuture<'static, Result<BoxStream<'static, Result<RecordBatch>>>>;
139
140/// Describes the behavior of the `FileStream` if file opening or scanning fails
141#[derive(Default)]
142pub enum OnError {
143    /// Fail the entire stream and return the underlying error
144    #[default]
145    Fail,
146    /// Continue scanning, ignoring the failed file
147    Skip,
148}
149
150/// Generic API for opening a file using an [`ObjectStore`] and resolving to a
151/// stream of [`RecordBatch`]
152///
153/// [`ObjectStore`]: object_store::ObjectStore
154pub trait FileOpener: Unpin + Send + Sync {
155    /// Asynchronously open the specified file and return a stream
156    /// of [`RecordBatch`]
157    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture>;
158}
159
160enum FileStreamState {
161    /// Actively processing readers, ready morsels, and planner work.
162    Scan {
163        /// The ready queues and active reader for the current file.
164        scan_state: Box<ScanState>,
165    },
166    /// Encountered an error
167    Error,
168    /// Finished scanning all requested data, possibly because a limit was reached
169    Done,
170}
171
172#[cfg(test)]
173mod tests {
174    use crate::file_scan_config::{FileScanConfig, FileScanConfigBuilder};
175    use crate::morsel::mocks::{
176        IoFutureId, MockMorselizer, MockPlanBuilder, MockPlanner, MorselId,
177        PendingPlannerBuilder, PollsToResolve,
178    };
179    use crate::source::DataSource;
180    use crate::tests::make_partition;
181    use crate::{PartitionedFile, TableSchema};
182    use arrow::array::{AsArray, RecordBatch};
183    use arrow::datatypes::{DataType, Field, Int32Type, Schema};
184    use datafusion_common::DataFusionError;
185    use datafusion_common::config::ConfigOptions;
186    use datafusion_common::error::Result;
187    use datafusion_execution::object_store::ObjectStoreUrl;
188    use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
189    use futures::{FutureExt as _, StreamExt as _};
190    use std::collections::{BTreeMap, VecDeque};
191    use std::sync::Arc;
192    use std::sync::atomic::{AtomicUsize, Ordering};
193
194    use crate::file_stream::{
195        FileOpenFuture, FileOpener, FileStream, FileStreamBuilder, OnError,
196        work_source::SharedWorkSource,
197    };
198    use crate::test_util::MockSource;
199
200    use datafusion_common::{assert_batches_eq, exec_err, internal_err};
201
202    /// Test identifier for one `FileStream` partition.
203    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
204    struct PartitionId(usize);
205
206    /// Test `FileOpener` which will simulate errors during file opening or scanning
207    #[derive(Default)]
208    struct TestOpener {
209        /// Index in stream of files which should throw an error while opening
210        error_opening_idx: Vec<usize>,
211        /// Index in stream of files which should throw an error while scanning
212        error_scanning_idx: Vec<usize>,
213        /// Index of last file in stream
214        current_idx: AtomicUsize,
215        /// `RecordBatch` to return
216        records: Vec<RecordBatch>,
217    }
218
219    impl FileOpener for TestOpener {
220        fn open(&self, _partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
221            let idx = self.current_idx.fetch_add(1, Ordering::SeqCst);
222
223            if self.error_opening_idx.contains(&idx) {
224                Ok(futures::future::ready(internal_err!("error opening")).boxed())
225            } else if self.error_scanning_idx.contains(&idx) {
226                let error = futures::future::ready(exec_err!("error scanning"));
227                let stream = futures::stream::once(error).boxed();
228                Ok(futures::future::ready(Ok(stream)).boxed())
229            } else {
230                let iterator = self.records.clone().into_iter().map(Ok);
231                let stream = futures::stream::iter(iterator).boxed();
232                Ok(futures::future::ready(Ok(stream)).boxed())
233            }
234        }
235    }
236
237    #[derive(Default)]
238    struct FileStreamTest {
239        /// Number of files in the stream
240        num_files: usize,
241        /// Global limit of records emitted by the stream
242        limit: Option<usize>,
243        /// Error-handling behavior of the stream
244        on_error: OnError,
245        /// Mock `FileOpener`
246        opener: TestOpener,
247    }
248
249    impl FileStreamTest {
250        pub fn new() -> Self {
251            Self::default()
252        }
253
254        /// Specify the number of files in the stream
255        pub fn with_num_files(mut self, num_files: usize) -> Self {
256            self.num_files = num_files;
257            self
258        }
259
260        /// Specify the limit
261        pub fn with_limit(mut self, limit: Option<usize>) -> Self {
262            self.limit = limit;
263            self
264        }
265
266        /// Specify the index of files in the stream which should
267        /// throw an error when opening
268        pub fn with_open_errors(mut self, idx: Vec<usize>) -> Self {
269            self.opener.error_opening_idx = idx;
270            self
271        }
272
273        /// Specify the index of files in the stream which should
274        /// throw an error when scanning
275        pub fn with_scan_errors(mut self, idx: Vec<usize>) -> Self {
276            self.opener.error_scanning_idx = idx;
277            self
278        }
279
280        /// Specify the behavior of the stream when an error occurs
281        pub fn with_on_error(mut self, on_error: OnError) -> Self {
282            self.on_error = on_error;
283            self
284        }
285
286        /// Specify the record batches that should be returned from each
287        /// file that is successfully scanned
288        pub fn with_records(mut self, records: Vec<RecordBatch>) -> Self {
289            self.opener.records = records;
290            self
291        }
292
293        /// Collect the results of the `FileStream`
294        pub async fn result(self) -> Result<Vec<RecordBatch>> {
295            let file_schema = self
296                .opener
297                .records
298                .first()
299                .map(|batch| batch.schema())
300                .unwrap_or_else(|| Arc::new(Schema::empty()));
301
302            // let ctx = SessionContext::new();
303            let mock_files: Vec<(String, u64)> = (0..self.num_files)
304                .map(|idx| (format!("mock_file{idx}"), 10_u64))
305                .collect();
306
307            // let mock_files_ref: Vec<(&str, u64)> = mock_files
308            //     .iter()
309            //     .map(|(name, size)| (name.as_str(), *size))
310            //     .collect();
311
312            let file_group = mock_files
313                .into_iter()
314                .map(|(name, size)| PartitionedFile::new(name, size))
315                .collect();
316
317            let on_error = self.on_error;
318
319            let table_schema = TableSchema::new(file_schema, vec![]);
320            let config = FileScanConfigBuilder::new(
321                ObjectStoreUrl::parse("test:///").unwrap(),
322                Arc::new(MockSource::new(table_schema)),
323            )
324            .with_file_group(file_group)
325            .with_limit(self.limit)
326            .build();
327            let metrics_set = ExecutionPlanMetricsSet::new();
328            let file_stream = FileStreamBuilder::new(&config)
329                .with_partition(0)
330                .with_file_opener(Arc::new(self.opener))
331                .with_metrics(&metrics_set)
332                .with_on_error(on_error)
333                .build()?;
334
335            file_stream
336                .collect::<Vec<_>>()
337                .await
338                .into_iter()
339                .collect::<Result<Vec<_>>>()
340        }
341    }
342
343    /// helper that creates a stream of 2 files with the same pair of batches in each ([0,1,2] and [0,1])
344    async fn create_and_collect(limit: Option<usize>) -> Vec<RecordBatch> {
345        FileStreamTest::new()
346            .with_records(vec![make_partition(3), make_partition(2)])
347            .with_num_files(2)
348            .with_limit(limit)
349            .result()
350            .await
351            .expect("error executing stream")
352    }
353
354    /// Create the smallest valid file scan config for builder validation tests.
355    fn builder_test_config() -> FileScanConfig {
356        let table_schema = TableSchema::new(Arc::new(Schema::empty()), vec![]);
357        FileScanConfigBuilder::new(
358            ObjectStoreUrl::parse("test:///").unwrap(),
359            Arc::new(MockSource::new(table_schema)),
360        )
361        .with_file(PartitionedFile::new("mock_file", 10))
362        .build()
363    }
364
365    /// Convenience helper to keep builder error assertions focused on the
366    /// specific missing or invalid input under test.
367    fn builder_error(builder: FileStreamBuilder<'_>) -> String {
368        builder.build().err().unwrap().to_string()
369    }
370
371    #[tokio::test]
372    async fn on_error_opening() -> Result<()> {
373        let batches = FileStreamTest::new()
374            .with_records(vec![make_partition(3), make_partition(2)])
375            .with_num_files(2)
376            .with_on_error(OnError::Skip)
377            .with_open_errors(vec![0])
378            .result()
379            .await?;
380
381        #[rustfmt::skip]
382        assert_batches_eq!(&[
383            "+---+",
384            "| i |",
385            "+---+",
386            "| 0 |",
387            "| 1 |",
388            "| 2 |",
389            "| 0 |",
390            "| 1 |",
391            "+---+",
392        ], &batches);
393
394        let batches = FileStreamTest::new()
395            .with_records(vec![make_partition(3), make_partition(2)])
396            .with_num_files(2)
397            .with_on_error(OnError::Skip)
398            .with_open_errors(vec![1])
399            .result()
400            .await?;
401
402        #[rustfmt::skip]
403        assert_batches_eq!(&[
404            "+---+",
405            "| i |",
406            "+---+",
407            "| 0 |",
408            "| 1 |",
409            "| 2 |",
410            "| 0 |",
411            "| 1 |",
412            "+---+",
413        ], &batches);
414
415        let batches = FileStreamTest::new()
416            .with_records(vec![make_partition(3), make_partition(2)])
417            .with_num_files(2)
418            .with_on_error(OnError::Skip)
419            .with_open_errors(vec![0, 1])
420            .result()
421            .await?;
422
423        #[rustfmt::skip]
424        assert_batches_eq!(&[
425            "++",
426            "++",
427        ], &batches);
428
429        Ok(())
430    }
431
432    #[tokio::test]
433    async fn on_error_scanning_fail() -> Result<()> {
434        let result = FileStreamTest::new()
435            .with_records(vec![make_partition(3), make_partition(2)])
436            .with_num_files(2)
437            .with_on_error(OnError::Fail)
438            .with_scan_errors(vec![1])
439            .result()
440            .await;
441
442        assert!(result.is_err());
443
444        Ok(())
445    }
446
447    #[tokio::test]
448    async fn on_error_opening_fail() -> Result<()> {
449        let result = FileStreamTest::new()
450            .with_records(vec![make_partition(3), make_partition(2)])
451            .with_num_files(2)
452            .with_on_error(OnError::Fail)
453            .with_open_errors(vec![1])
454            .result()
455            .await;
456
457        assert!(result.is_err());
458
459        Ok(())
460    }
461
462    #[tokio::test]
463    async fn on_error_scanning() -> Result<()> {
464        let batches = FileStreamTest::new()
465            .with_records(vec![make_partition(3), make_partition(2)])
466            .with_num_files(2)
467            .with_on_error(OnError::Skip)
468            .with_scan_errors(vec![0])
469            .result()
470            .await?;
471
472        #[rustfmt::skip]
473        assert_batches_eq!(&[
474            "+---+",
475            "| i |",
476            "+---+",
477            "| 0 |",
478            "| 1 |",
479            "| 2 |",
480            "| 0 |",
481            "| 1 |",
482            "+---+",
483        ], &batches);
484
485        let batches = FileStreamTest::new()
486            .with_records(vec![make_partition(3), make_partition(2)])
487            .with_num_files(2)
488            .with_on_error(OnError::Skip)
489            .with_scan_errors(vec![1])
490            .result()
491            .await?;
492
493        #[rustfmt::skip]
494        assert_batches_eq!(&[
495            "+---+",
496            "| i |",
497            "+---+",
498            "| 0 |",
499            "| 1 |",
500            "| 2 |",
501            "| 0 |",
502            "| 1 |",
503            "+---+",
504        ], &batches);
505
506        let batches = FileStreamTest::new()
507            .with_records(vec![make_partition(3), make_partition(2)])
508            .with_num_files(2)
509            .with_on_error(OnError::Skip)
510            .with_scan_errors(vec![0, 1])
511            .result()
512            .await?;
513
514        #[rustfmt::skip]
515        assert_batches_eq!(&[
516            "++",
517            "++",
518        ], &batches);
519
520        Ok(())
521    }
522
523    #[tokio::test]
524    async fn on_error_mixed() -> Result<()> {
525        let batches = FileStreamTest::new()
526            .with_records(vec![make_partition(3), make_partition(2)])
527            .with_num_files(3)
528            .with_on_error(OnError::Skip)
529            .with_open_errors(vec![1])
530            .with_scan_errors(vec![0])
531            .result()
532            .await?;
533
534        #[rustfmt::skip]
535        assert_batches_eq!(&[
536            "+---+",
537            "| i |",
538            "+---+",
539            "| 0 |",
540            "| 1 |",
541            "| 2 |",
542            "| 0 |",
543            "| 1 |",
544            "+---+",
545        ], &batches);
546
547        let batches = FileStreamTest::new()
548            .with_records(vec![make_partition(3), make_partition(2)])
549            .with_num_files(3)
550            .with_on_error(OnError::Skip)
551            .with_open_errors(vec![0])
552            .with_scan_errors(vec![1])
553            .result()
554            .await?;
555
556        #[rustfmt::skip]
557        assert_batches_eq!(&[
558            "+---+",
559            "| i |",
560            "+---+",
561            "| 0 |",
562            "| 1 |",
563            "| 2 |",
564            "| 0 |",
565            "| 1 |",
566            "+---+",
567        ], &batches);
568
569        let batches = FileStreamTest::new()
570            .with_records(vec![make_partition(3), make_partition(2)])
571            .with_num_files(3)
572            .with_on_error(OnError::Skip)
573            .with_open_errors(vec![2])
574            .with_scan_errors(vec![0, 1])
575            .result()
576            .await?;
577
578        #[rustfmt::skip]
579        assert_batches_eq!(&[
580            "++",
581            "++",
582        ], &batches);
583
584        let batches = FileStreamTest::new()
585            .with_records(vec![make_partition(3), make_partition(2)])
586            .with_num_files(3)
587            .with_on_error(OnError::Skip)
588            .with_open_errors(vec![0, 2])
589            .with_scan_errors(vec![1])
590            .result()
591            .await?;
592
593        #[rustfmt::skip]
594        assert_batches_eq!(&[
595            "++",
596            "++",
597        ], &batches);
598
599        Ok(())
600    }
601
602    #[tokio::test]
603    async fn without_limit() -> Result<()> {
604        let batches = create_and_collect(None).await;
605
606        #[rustfmt::skip]
607        assert_batches_eq!(&[
608            "+---+",
609            "| i |",
610            "+---+",
611            "| 0 |",
612            "| 1 |",
613            "| 2 |",
614            "| 0 |",
615            "| 1 |",
616            "| 0 |",
617            "| 1 |",
618            "| 2 |",
619            "| 0 |",
620            "| 1 |",
621            "+---+",
622        ], &batches);
623
624        Ok(())
625    }
626
627    #[tokio::test]
628    async fn with_limit_between_files() -> Result<()> {
629        let batches = create_and_collect(Some(5)).await;
630        #[rustfmt::skip]
631        assert_batches_eq!(&[
632            "+---+",
633            "| i |",
634            "+---+",
635            "| 0 |",
636            "| 1 |",
637            "| 2 |",
638            "| 0 |",
639            "| 1 |",
640            "+---+",
641        ], &batches);
642
643        Ok(())
644    }
645
646    #[tokio::test]
647    async fn with_limit_at_middle_of_batch() -> Result<()> {
648        let batches = create_and_collect(Some(6)).await;
649        #[rustfmt::skip]
650        assert_batches_eq!(&[
651            "+---+",
652            "| i |",
653            "+---+",
654            "| 0 |",
655            "| 1 |",
656            "| 2 |",
657            "| 0 |",
658            "| 1 |",
659            "| 0 |",
660            "+---+",
661        ], &batches);
662
663        Ok(())
664    }
665
666    #[test]
667    fn builder_requires_partition_file_opener_and_metrics() {
668        let config = builder_test_config();
669
670        let err = builder_error(FileStreamBuilder::new(&config));
671        assert!(err.contains("FileStreamBuilder missing required partition"));
672
673        let err = builder_error(FileStreamBuilder::new(&config).with_partition(0));
674        assert!(err.contains("FileStreamBuilder missing required morselizer"));
675
676        let err = builder_error(
677            FileStreamBuilder::new(&config)
678                .with_partition(0)
679                .with_file_opener(Arc::new(TestOpener::default())),
680        );
681        assert!(err.contains("FileStreamBuilder missing required metrics"));
682    }
683
684    #[test]
685    fn builder_errors_on_invalid_partition() {
686        let config = builder_test_config();
687        let metrics = ExecutionPlanMetricsSet::new();
688
689        let err = builder_error(
690            FileStreamBuilder::new(&config)
691                .with_partition(1)
692                .with_file_opener(Arc::new(TestOpener::default()))
693                .with_metrics(&metrics),
694        );
695        assert!(err.contains("FileStreamBuilder invalid partition index: 1"));
696    }
697
698    /// Verifies the simplest morsel-driven flow: one planner produces one
699    /// morsel immediately, and that morsel is then scanned to completion.
700    #[tokio::test]
701    async fn morsel_no_io() -> Result<()> {
702        let test = FileStreamMorselTest::new().with_file(
703            MockPlanner::builder("file1.parquet")
704                .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 42))
705                .return_none(),
706        );
707
708        insta::assert_snapshot!(test.run().await.unwrap(), @r"
709        ----- Output Stream -----
710        Batch: 42
711        Done
712        ----- File Stream Events -----
713        morselize_file: file1.parquet
714        planner_created: file1.parquet
715        planner_called: file1.parquet
716        morsel_produced: file1.parquet, MorselId(10)
717        morsel_stream_started: MorselId(10)
718        morsel_stream_batch_produced: MorselId(10), BatchId(42)
719        morsel_stream_finished: MorselId(10)
720        ");
721
722        Ok(())
723    }
724
725    /// Verifies that a planner can block on one I/O phase and then produce a
726    /// morsel containing two batches.
727    #[tokio::test]
728    async fn morsel_single_io_two_batches() -> Result<()> {
729        let test = FileStreamMorselTest::new().with_file(
730            MockPlanner::builder("file1.parquet")
731                .add_plan(
732                    PendingPlannerBuilder::new(IoFutureId(1))
733                        .with_polls_to_resolve(PollsToResolve(1)),
734                )
735                .add_plan(
736                    MockPlanBuilder::new()
737                        .with_morsel_batches(MorselId(10), vec![42, 43]),
738                )
739                .return_none(),
740        );
741
742        insta::assert_snapshot!(test.run().await.unwrap(), @r"
743        ----- Output Stream -----
744        Batch: 42
745        Batch: 43
746        Done
747        ----- File Stream Events -----
748        morselize_file: file1.parquet
749        planner_created: file1.parquet
750        planner_called: file1.parquet
751        io_future_created: file1.parquet, IoFutureId(1)
752        io_future_polled: file1.parquet, IoFutureId(1)
753        io_future_polled: file1.parquet, IoFutureId(1)
754        io_future_resolved: file1.parquet, IoFutureId(1)
755        planner_called: file1.parquet
756        morsel_produced: file1.parquet, MorselId(10)
757        morsel_stream_started: MorselId(10)
758        morsel_stream_batch_produced: MorselId(10), BatchId(42)
759        morsel_stream_batch_produced: MorselId(10), BatchId(43)
760        morsel_stream_finished: MorselId(10)
761        ");
762
763        Ok(())
764    }
765
766    /// Verifies that a planner can traverse two sequential I/O phases before
767    /// producing one batch, similar to Parquet.
768    #[tokio::test]
769    async fn morsel_two_ios_one_batch() -> Result<()> {
770        let test = FileStreamMorselTest::new().with_file(
771            MockPlanner::builder("file1.parquet")
772                .add_plan(PendingPlannerBuilder::new(IoFutureId(1)))
773                .add_plan(PendingPlannerBuilder::new(IoFutureId(2)))
774                .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 42))
775                .return_none(),
776        );
777
778        insta::assert_snapshot!(test.run().await.unwrap(), @r"
779        ----- Output Stream -----
780        Batch: 42
781        Done
782        ----- File Stream Events -----
783        morselize_file: file1.parquet
784        planner_created: file1.parquet
785        planner_called: file1.parquet
786        io_future_created: file1.parquet, IoFutureId(1)
787        io_future_polled: file1.parquet, IoFutureId(1)
788        io_future_resolved: file1.parquet, IoFutureId(1)
789        planner_called: file1.parquet
790        io_future_created: file1.parquet, IoFutureId(2)
791        io_future_polled: file1.parquet, IoFutureId(2)
792        io_future_resolved: file1.parquet, IoFutureId(2)
793        planner_called: file1.parquet
794        morsel_produced: file1.parquet, MorselId(10)
795        morsel_stream_started: MorselId(10)
796        morsel_stream_batch_produced: MorselId(10), BatchId(42)
797        morsel_stream_finished: MorselId(10)
798        ");
799
800        Ok(())
801    }
802
803    /// Verifies that a planner I/O future can fail and terminate the stream.
804    #[tokio::test]
805    async fn morsel_io_error() -> Result<()> {
806        let test = FileStreamMorselTest::new().with_file(
807            MockPlanner::builder("file1.parquet").add_plan(
808                PendingPlannerBuilder::new(IoFutureId(1))
809                    .with_error("io failed while opening file"),
810            ),
811        );
812
813        insta::assert_snapshot!(test.run().await.unwrap(), @r"
814        ----- Output Stream -----
815        Error: io failed while opening file
816        Done
817        ----- File Stream Events -----
818        morselize_file: file1.parquet
819        planner_created: file1.parquet
820        planner_called: file1.parquet
821        io_future_created: file1.parquet, IoFutureId(1)
822        io_future_polled: file1.parquet, IoFutureId(1)
823        io_future_errored: file1.parquet, IoFutureId(1), io failed while opening file
824        ");
825
826        Ok(())
827    }
828
829    /// Verifies that pending planner I/O does not block draining the current
830    /// morsel stream.
831    #[tokio::test]
832    async fn morsel_pending_planner_does_not_block_active_reader() -> Result<()> {
833        let test = FileStreamMorselTest::new().with_file(
834            MockPlanner::builder("file1.parquet")
835                .add_plan(
836                    MockPlanBuilder::new()
837                        .with_morsel_batches(MorselId(10), vec![41, 42])
838                        .with_pending_planner(IoFutureId(1), PollsToResolve(3), Ok(())),
839                )
840                .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 43))
841                .return_none(),
842        );
843
844        // The key events are:
845        // 1. the first `planner_called` produces `MorselId(10)` and creates `IoFutureId(1)`
846        // 2. `MorselId(10)` continues yielding both batches while that I/O is pending
847        // 3. after the I/O resolves, planning resumes and yields `MorselId(11)`
848        insta::assert_snapshot!(test.run().await.unwrap(), @r"
849        ----- Output Stream -----
850        Batch: 41
851        Batch: 42
852        Batch: 43
853        Done
854        ----- File Stream Events -----
855        morselize_file: file1.parquet
856        planner_created: file1.parquet
857        planner_called: file1.parquet
858        morsel_produced: file1.parquet, MorselId(10)
859        io_future_created: file1.parquet, IoFutureId(1)
860        io_future_polled: file1.parquet, IoFutureId(1)
861        morsel_stream_started: MorselId(10)
862        io_future_polled: file1.parquet, IoFutureId(1)
863        morsel_stream_batch_produced: MorselId(10), BatchId(41)
864        io_future_polled: file1.parquet, IoFutureId(1)
865        morsel_stream_batch_produced: MorselId(10), BatchId(42)
866        io_future_polled: file1.parquet, IoFutureId(1)
867        io_future_resolved: file1.parquet, IoFutureId(1)
868        morsel_stream_finished: MorselId(10)
869        planner_called: file1.parquet
870        morsel_produced: file1.parquet, MorselId(11)
871        morsel_stream_started: MorselId(11)
872        morsel_stream_batch_produced: MorselId(11), BatchId(43)
873        morsel_stream_finished: MorselId(11)
874        ");
875
876        Ok(())
877    }
878
879    /// Verifies that one `plan()` call can return a ready child planner, which
880    /// is then called to produce the morsel.
881    #[tokio::test]
882    async fn morsel_ready_child_planner() -> Result<()> {
883        let child_planner = MockPlanner::builder("child planner")
884            .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 42))
885            .return_none();
886
887        let test = FileStreamMorselTest::new().with_file(
888            MockPlanner::builder("file1.parquet")
889                .add_plan(MockPlanBuilder::new().with_ready_planner(child_planner))
890                .return_none(),
891        );
892
893        insta::assert_snapshot!(test.run().await.unwrap(), @r"
894        ----- Output Stream -----
895        Batch: 42
896        Done
897        ----- File Stream Events -----
898        morselize_file: file1.parquet
899        planner_created: file1.parquet
900        planner_called: file1.parquet
901        planner_created: child planner
902        planner_called: child planner
903        morsel_produced: child planner, MorselId(10)
904        morsel_stream_started: MorselId(10)
905        morsel_stream_batch_produced: MorselId(10), BatchId(42)
906        morsel_stream_finished: MorselId(10)
907        ");
908
909        Ok(())
910    }
911
912    /// Verifies that planning can fail after a successful I/O phase.
913    #[tokio::test]
914    async fn morsel_plan_error_after_io() -> Result<()> {
915        let test = FileStreamMorselTest::new().with_file(
916            MockPlanner::builder("file1.parquet")
917                .add_plan(PendingPlannerBuilder::new(IoFutureId(1)))
918                .return_error("planner failed after io"),
919        );
920
921        insta::assert_snapshot!(test.run().await.unwrap(), @r"
922        ----- Output Stream -----
923        Error: planner failed after io
924        Done
925        ----- File Stream Events -----
926        morselize_file: file1.parquet
927        planner_created: file1.parquet
928        planner_called: file1.parquet
929        io_future_created: file1.parquet, IoFutureId(1)
930        io_future_polled: file1.parquet, IoFutureId(1)
931        io_future_resolved: file1.parquet, IoFutureId(1)
932        planner_called: file1.parquet
933        ");
934
935        Ok(())
936    }
937
938    /// Verifies that `FileStream` scans multiple files in order.
939    #[tokio::test]
940    async fn morsel_multiple_files() -> Result<()> {
941        let test = FileStreamMorselTest::new()
942            .with_file(
943                MockPlanner::builder("file1.parquet")
944                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 41))
945                    .return_none(),
946            )
947            .with_file(
948                MockPlanner::builder("file2.parquet")
949                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 42))
950                    .return_none(),
951            );
952
953        insta::assert_snapshot!(test.run().await.unwrap(), @r"
954        ----- Output Stream -----
955        Batch: 41
956        Batch: 42
957        Done
958        ----- File Stream Events -----
959        morselize_file: file1.parquet
960        planner_created: file1.parquet
961        planner_called: file1.parquet
962        morsel_produced: file1.parquet, MorselId(10)
963        morsel_stream_started: MorselId(10)
964        morsel_stream_batch_produced: MorselId(10), BatchId(41)
965        morsel_stream_finished: MorselId(10)
966        morselize_file: file2.parquet
967        planner_created: file2.parquet
968        planner_called: file2.parquet
969        morsel_produced: file2.parquet, MorselId(11)
970        morsel_stream_started: MorselId(11)
971        morsel_stream_batch_produced: MorselId(11), BatchId(42)
972        morsel_stream_finished: MorselId(11)
973        ");
974
975        Ok(())
976    }
977
978    /// Verifies that a global limit can stop the stream before a second file is opened.
979    #[tokio::test]
980    async fn morsel_limit_prevents_second_file() -> Result<()> {
981        let test = FileStreamMorselTest::new()
982            .with_file(
983                MockPlanner::builder("file1.parquet")
984                    .add_plan(
985                        MockPlanBuilder::new()
986                            .with_morsel_batches(MorselId(10), vec![41, 42]),
987                    )
988                    .return_none(),
989            )
990            .with_file(
991                MockPlanner::builder("file2.parquet")
992                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 43))
993                    .return_none(),
994            )
995            .with_limit(1);
996
997        // Note the snapshot should not ever see planner id2
998        insta::assert_snapshot!(test.run().await.unwrap(), @r"
999        ----- Output Stream -----
1000        Batch: 41
1001        Done
1002        ----- File Stream Events -----
1003        morselize_file: file1.parquet
1004        planner_created: file1.parquet
1005        planner_called: file1.parquet
1006        morsel_produced: file1.parquet, MorselId(10)
1007        morsel_stream_started: MorselId(10)
1008        morsel_stream_batch_produced: MorselId(10), BatchId(41)
1009        ");
1010
1011        Ok(())
1012    }
1013
1014    /// Return a morsel test with two partitions:
1015    /// Partition 0: file1, file2, file3
1016    /// Partition 1: file4
1017    ///
1018    /// Partition 1 has only 1 file but it polled first 4 times
1019    fn two_partition_morsel_test() -> FileStreamMorselTest {
1020        FileStreamMorselTest::new()
1021            // Partition 0 has three files
1022            .with_file_in_partition(
1023                PartitionId(0),
1024                MockPlanner::builder("file1.parquet")
1025                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 101))
1026                    .return_none(),
1027            )
1028            .with_file_in_partition(
1029                PartitionId(0),
1030                MockPlanner::builder("file2.parquet")
1031                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 102))
1032                    .return_none(),
1033            )
1034            .with_file_in_partition(
1035                PartitionId(0),
1036                MockPlanner::builder("file3.parquet")
1037                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(12), 103))
1038                    .return_none(),
1039            )
1040            // Partition 1 has only one file, but is polled first
1041            .with_file_in_partition(
1042                PartitionId(1),
1043                MockPlanner::builder("file4.parquet")
1044                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(13), 201))
1045                    .return_none(),
1046            )
1047            .with_reads(vec![
1048                PartitionId(1),
1049                PartitionId(1),
1050                PartitionId(1),
1051                PartitionId(1),
1052                PartitionId(1),
1053            ])
1054    }
1055
1056    /// Verifies that an idle sibling stream can steal shared files from
1057    /// another stream once it exhausts its own local work.
1058    #[tokio::test]
1059    async fn morsel_shared_files_can_be_stolen() -> Result<()> {
1060        let test = two_partition_morsel_test().with_file_stream_events(false);
1061
1062        // Partition 0 starts with 3 files, but Partition 1 is polled first.
1063        // Since Partition 1 is polled first, it will run all the files even those
1064        // that were assigned to Partition 0.
1065        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1066        ----- Partition 0 -----
1067        Done
1068        ----- Partition 1 -----
1069        Batch: 101
1070        Batch: 102
1071        Batch: 103
1072        Batch: 201
1073        Done
1074        ----- File Stream Events -----
1075        (omitted due to with_file_stream_events(false))
1076        ");
1077
1078        Ok(())
1079    }
1080
1081    /// Verifies that a stream that must preserve order keeps its files local
1082    /// and therefore cannot steal from a sibling shared queue.
1083    #[tokio::test]
1084    async fn morsel_preserve_order_keeps_files_local() -> Result<()> {
1085        // same fixture as `morsel_shared_files_can_be_stolen` but marked as
1086        // preserve-order
1087        let test = two_partition_morsel_test()
1088            .with_preserve_order(true)
1089            .with_file_stream_events(false);
1090
1091        // Even though that Partition 1 is polled first, it can not steal files
1092        // from partition 0. The three files originally assigned to Partition 0
1093        // must be evaluated by Partition 0.
1094        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1095        ----- Partition 0 -----
1096        Batch: 101
1097        Batch: 102
1098        Batch: 103
1099        Done
1100        ----- Partition 1 -----
1101        Batch: 201
1102        Done
1103        ----- File Stream Events -----
1104        (omitted due to with_file_stream_events(false))
1105        ");
1106
1107        Ok(())
1108    }
1109
1110    /// Verifies that `partitioned_by_file_group` disables shared work stealing.
1111    #[tokio::test]
1112    async fn morsel_partitioned_by_file_group_keeps_files_local() -> Result<()> {
1113        // same fixture as `morsel_shared_files_can_be_stolen` but marked as
1114        // preserve-partitioned
1115        let test = two_partition_morsel_test()
1116            .with_partitioned_by_file_group(true)
1117            .with_file_stream_events(false);
1118
1119        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1120        ----- Partition 0 -----
1121        Batch: 101
1122        Batch: 102
1123        Batch: 103
1124        Done
1125        ----- Partition 1 -----
1126        Batch: 201
1127        Done
1128        ----- File Stream Events -----
1129        (omitted due to with_file_stream_events(false))
1130        ");
1131
1132        Ok(())
1133    }
1134
1135    /// Verifies that disabling `enable_file_stream_work_stealing` keeps each
1136    /// stream's files local, so a sibling cannot steal them at runtime.
1137    ///
1138    /// Covers <https://github.com/apache/datafusion/issues/23293>: executors
1139    /// that run each output partition as an isolated task in a separate process
1140    /// (Ballista, datafusion-distributed) poll only their own partition, so the
1141    /// shared work queue would let that one partition drain files belonging to
1142    /// its siblings. Disabling the flag falls back to per-partition file groups.
1143    #[tokio::test]
1144    async fn morsel_disabled_work_stealing_keeps_files_local() -> Result<()> {
1145        // same fixture as `morsel_shared_files_can_be_stolen`, but with work
1146        // stealing disabled via config
1147        let test = two_partition_morsel_test()
1148            .with_enable_file_stream_work_stealing(false)
1149            .with_file_stream_events(false);
1150
1151        // Even though Partition 1 is polled first, it cannot steal the three
1152        // files assigned to Partition 0; each partition reads only its own.
1153        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1154        ----- Partition 0 -----
1155        Batch: 101
1156        Batch: 102
1157        Batch: 103
1158        Done
1159        ----- Partition 1 -----
1160        Batch: 201
1161        Done
1162        ----- File Stream Events -----
1163        (omitted due to with_file_stream_events(false))
1164        ");
1165
1166        Ok(())
1167    }
1168
1169    /// Verifies that an empty sibling can immediately steal shared files when
1170    /// it is polled before the stream that originally owned them.
1171    #[tokio::test]
1172    async fn morsel_empty_sibling_can_steal() -> Result<()> {
1173        let test = FileStreamMorselTest::new()
1174            .with_file_in_partition(
1175                PartitionId(0),
1176                MockPlanner::builder("file1.parquet")
1177                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 101))
1178                    .return_none(),
1179            )
1180            .with_file_in_partition(
1181                PartitionId(0),
1182                MockPlanner::builder("file2.parquet")
1183                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 102))
1184                    .return_none(),
1185            )
1186            // Poll the empty sibling first so it steals both files.
1187            .with_reads(vec![PartitionId(1), PartitionId(1), PartitionId(1)])
1188            .with_file_stream_events(false);
1189
1190        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1191        ----- Partition 0 -----
1192        Done
1193        ----- Partition 1 -----
1194        Batch: 101
1195        Batch: 102
1196        Done
1197        ----- File Stream Events -----
1198        (omitted due to with_file_stream_events(false))
1199        ");
1200
1201        Ok(())
1202    }
1203
1204    /// Ensures that if a sibling is built and polled
1205    /// before another sibling has been built and contributed its files to the
1206    /// shared queue, the first sibling does not finish prematurely.
1207    #[tokio::test]
1208    async fn morsel_empty_sibling_can_finish_before_shared_work_exists() -> Result<()> {
1209        let test = FileStreamMorselTest::new()
1210            .with_file_in_partition(
1211                PartitionId(0),
1212                MockPlanner::builder("file1.parquet")
1213                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 101))
1214                    .return_none(),
1215            )
1216            .with_file_in_partition(
1217                PartitionId(0),
1218                MockPlanner::builder("file2.parquet")
1219                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 102))
1220                    .return_none(),
1221            )
1222            // Build streams lazily so partition 1 can poll the shared queue
1223            // before partition 0 has contributed its files. Once partition 0
1224            // is built, a later poll of partition 1 can still steal one of
1225            // them from the shared queue.
1226            .with_build_streams_on_first_read(true)
1227            .with_reads(vec![PartitionId(1), PartitionId(0), PartitionId(1)])
1228            .with_file_stream_events(false);
1229
1230        // Partition 1 polls too early once, then later steals one file after
1231        // partition 0 has populated the shared queue.
1232        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1233        ----- Partition 0 -----
1234        Batch: 102
1235        Done
1236        ----- Partition 1 -----
1237        Batch: 101
1238        Done
1239        ----- File Stream Events -----
1240        (omitted due to with_file_stream_events(false))
1241        ");
1242
1243        Ok(())
1244    }
1245
1246    /// Verifies that a sibling hitting its limit does not count shared files
1247    /// left in the queue as already processed by that stream.
1248    #[tokio::test]
1249    async fn morsel_shared_limit_does_not_double_count_files_processed() -> Result<()> {
1250        let test = two_partition_morsel_test();
1251        let unlimited_config = test.test_config();
1252        let limited_config = test.clone().with_limit(1).test_config();
1253        let shared_work_source = limited_config
1254            .create_sibling_state(&ConfigOptions::default())
1255            .and_then(|state| state.as_ref().downcast_ref::<SharedWorkSource>().cloned())
1256            .expect("shared work source");
1257        let limited_metrics = ExecutionPlanMetricsSet::new();
1258        let unlimited_metrics = ExecutionPlanMetricsSet::new();
1259
1260        let limited_stream = FileStreamBuilder::new(&limited_config)
1261            .with_partition(1)
1262            .with_shared_work_source(Some(shared_work_source.clone()))
1263            .with_morselizer(Box::new(test.morselizer.clone()))
1264            .with_metrics(&limited_metrics)
1265            .build()?;
1266
1267        let unlimited_stream = FileStreamBuilder::new(&unlimited_config)
1268            .with_partition(0)
1269            .with_shared_work_source(Some(shared_work_source))
1270            .with_morselizer(Box::new(test.morselizer))
1271            .with_metrics(&unlimited_metrics)
1272            .build()?;
1273
1274        let limited_output = drain_stream_output(limited_stream).await?;
1275        let unlimited_output = drain_stream_output(unlimited_stream).await?;
1276
1277        insta::assert_snapshot!(format!(
1278            "----- Limited Stream -----\n{limited_output}\n----- Unlimited Stream -----\n{unlimited_output}"
1279        ), @r"
1280        ----- Limited Stream -----
1281        Batch: 101
1282        ----- Unlimited Stream -----
1283        Batch: 102
1284        Batch: 103
1285        Batch: 201
1286        ");
1287
1288        assert_eq!(
1289            metric_count(&limited_metrics, "files_opened"),
1290            1,
1291            "the limited stream should only open the file that produced its output"
1292        );
1293        assert_eq!(
1294            metric_count(&limited_metrics, "files_processed"),
1295            1,
1296            "the limited stream should only mark its own file as processed"
1297        );
1298        assert_eq!(
1299            metric_count(&unlimited_metrics, "files_opened"),
1300            3,
1301            "the draining stream should open the remaining shared files"
1302        );
1303        assert_eq!(
1304            metric_count(&unlimited_metrics, "files_processed"),
1305            3,
1306            "the draining stream should process exactly the files it opened"
1307        );
1308
1309        Ok(())
1310    }
1311
1312    /// Verifies that one fast sibling can drain shared files that originated
1313    /// in more than one other partition.
1314    #[tokio::test]
1315    async fn morsel_one_sibling_can_drain_multiple_siblings() -> Result<()> {
1316        let test = FileStreamMorselTest::new()
1317            .with_file_in_partition(
1318                PartitionId(0),
1319                MockPlanner::builder("file1.parquet")
1320                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(10), 101))
1321                    .return_none(),
1322            )
1323            // Partition 1 has two files
1324            .with_file_in_partition(
1325                PartitionId(1),
1326                MockPlanner::builder("file2.parquet")
1327                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(11), 102))
1328                    .return_none(),
1329            )
1330            .with_file_in_partition(
1331                PartitionId(1),
1332                MockPlanner::builder("file3.parquet")
1333                    .add_plan(MockPlanBuilder::new().with_morsel(MorselId(12), 103))
1334                    .return_none(),
1335            )
1336            // Partition 2 starts empty but is polled first, so it should drain
1337            // the shared queue across both sibling partitions.
1338            .with_reads(vec![
1339                PartitionId(2),
1340                PartitionId(2),
1341                PartitionId(1),
1342                PartitionId(2),
1343            ])
1344            .with_file_stream_events(false);
1345
1346        insta::assert_snapshot!(test.run().await.unwrap(), @r"
1347        ----- Partition 0 -----
1348        Done
1349        ----- Partition 1 -----
1350        Batch: 103
1351        Done
1352        ----- Partition 2 -----
1353        Batch: 101
1354        Batch: 102
1355        Done
1356        ----- File Stream Events -----
1357        (omitted due to with_file_stream_events(false))
1358        ");
1359
1360        Ok(())
1361    }
1362
1363    /// Tests how one or more `FileStream`s consume morselized file work.
1364    #[derive(Clone)]
1365    struct FileStreamMorselTest {
1366        morselizer: MockMorselizer,
1367        partition_files: BTreeMap<PartitionId, Vec<String>>,
1368        preserve_order: bool,
1369        partitioned_by_file_group: bool,
1370        enable_file_stream_work_stealing: bool,
1371        file_stream_events: bool,
1372        build_streams_on_first_read: bool,
1373        reads: Vec<PartitionId>,
1374        limit: Option<usize>,
1375    }
1376
1377    impl FileStreamMorselTest {
1378        /// Creates an empty test harness.
1379        fn new() -> Self {
1380            Self {
1381                morselizer: MockMorselizer::new(),
1382                partition_files: BTreeMap::new(),
1383                preserve_order: false,
1384                partitioned_by_file_group: false,
1385                enable_file_stream_work_stealing: true,
1386                file_stream_events: true,
1387                build_streams_on_first_read: false,
1388                reads: vec![],
1389                limit: None,
1390            }
1391        }
1392
1393        /// Adds one file and its root planner to partition 0.
1394        fn with_file(self, planner: impl Into<MockPlanner>) -> Self {
1395            self.with_file_in_partition(PartitionId(0), planner)
1396        }
1397
1398        /// Adds one file and its root planner to the specified input partition.
1399        fn with_file_in_partition(
1400            mut self,
1401            partition: PartitionId,
1402            planner: impl Into<MockPlanner>,
1403        ) -> Self {
1404            let planner = planner.into();
1405            let file_path = planner.file_path().to_string();
1406            self.morselizer = self.morselizer.with_planner(planner);
1407            self.partition_files
1408                .entry(partition)
1409                .or_default()
1410                .push(file_path);
1411            self
1412        }
1413
1414        /// Marks the stream (and all partitions) to preserve the specified file
1415        /// order.
1416        fn with_preserve_order(mut self, preserve_order: bool) -> Self {
1417            self.preserve_order = preserve_order;
1418            self
1419        }
1420
1421        /// Marks the test scan as pre-partitioned by file group, which should
1422        /// force each stream to keep its own files local.
1423        fn with_partitioned_by_file_group(
1424            mut self,
1425            partitioned_by_file_group: bool,
1426        ) -> Self {
1427            self.partitioned_by_file_group = partitioned_by_file_group;
1428            self
1429        }
1430
1431        /// Sets `datafusion.execution.enable_file_stream_work_stealing`. When
1432        /// disabled, each stream keeps its own files local instead of sharing a
1433        /// work queue with its siblings.
1434        fn with_enable_file_stream_work_stealing(mut self, enable: bool) -> Self {
1435            self.enable_file_stream_work_stealing = enable;
1436            self
1437        }
1438
1439        /// Controls whether scheduler events are included in the snapshot.
1440        ///
1441        /// When disabled, `run()` still includes the event section header but
1442        /// replaces the trace with a fixed placeholder so tests can focus only
1443        /// on the output batches.
1444        fn with_file_stream_events(mut self, file_stream_events: bool) -> Self {
1445            self.file_stream_events = file_stream_events;
1446            self
1447        }
1448
1449        /// Controls whether streams are all built up front or lazily on their
1450        /// first read.
1451        ///
1452        /// The default builds all streams before polling begins, which matches
1453        /// normal execution. Tests may enable lazy creation to model races
1454        /// where one sibling polls before another has contributed its files to
1455        /// the shared queue.
1456        fn with_build_streams_on_first_read(
1457            mut self,
1458            build_streams_on_first_read: bool,
1459        ) -> Self {
1460            self.build_streams_on_first_read = build_streams_on_first_read;
1461            self
1462        }
1463
1464        /// Sets the partition polling order.
1465        ///
1466        /// `run()` polls these partitions in the listed order first. After
1467        /// those explicit reads are exhausted, it completes to round
1468        /// robin across all configured partitions, skipping any streams that
1469        /// have already finished.
1470        ///
1471        /// This allows testing early scheduling decisions explicit in a test
1472        /// while avoiding a fully scripted poll trace for the remainder.
1473        fn with_reads(mut self, reads: Vec<PartitionId>) -> Self {
1474            self.reads = reads;
1475            self
1476        }
1477
1478        /// Sets a global output limit for all streams created by this test.
1479        fn with_limit(mut self, limit: usize) -> Self {
1480            self.limit = Some(limit);
1481            self
1482        }
1483
1484        /// Runs the test and returns combined stream output and scheduler
1485        /// trace text.
1486        async fn run(self) -> Result<String> {
1487            let observer = self.morselizer.observer().clone();
1488            observer.clear();
1489
1490            let metrics_set = ExecutionPlanMetricsSet::new();
1491            let partition_count = self.num_partitions();
1492
1493            let mut partitions = (0..partition_count)
1494                .map(|_| PartitionState::new())
1495                .collect::<Vec<_>>();
1496
1497            let mut build_order = Vec::new();
1498            for partition in self.reads.iter().map(|partition| partition.0) {
1499                if !build_order.contains(&partition) {
1500                    build_order.push(partition);
1501                }
1502            }
1503            for partition in 0..partition_count {
1504                if !build_order.contains(&partition) {
1505                    build_order.push(partition);
1506                }
1507            }
1508
1509            let config = self.test_config();
1510            // `DataSourceExec::execute` creates one execution-local shared
1511            // state object via `create_sibling_state()` and then passes it
1512            // to `open_with_sibling_state(...)`. These tests build
1513            // `FileStream`s directly, bypassing `DataSourceExec`, so they must
1514            // perform the same setup explicitly when exercising sibling-stream
1515            // work stealing.
1516            let mut options = ConfigOptions::default();
1517            options.execution.enable_file_stream_work_stealing =
1518                self.enable_file_stream_work_stealing;
1519            let shared_work_source =
1520                config.create_sibling_state(&options).and_then(|state| {
1521                    state.as_ref().downcast_ref::<SharedWorkSource>().cloned()
1522                });
1523            if !self.build_streams_on_first_read {
1524                for partition in build_order {
1525                    let stream = FileStreamBuilder::new(&config)
1526                        .with_partition(partition)
1527                        .with_shared_work_source(shared_work_source.clone())
1528                        .with_morselizer(Box::new(self.morselizer.clone()))
1529                        .with_metrics(&metrics_set)
1530                        .build()?;
1531                    partitions[partition].set_stream(stream);
1532                }
1533            }
1534
1535            let mut initial_reads: VecDeque<_> = self.reads.into();
1536            let mut next_round_robin = 0;
1537
1538            while !initial_reads.is_empty()
1539                || partitions.iter().any(PartitionState::is_active)
1540            {
1541                let partition = if let Some(partition) = initial_reads.pop_front() {
1542                    partition.0
1543                } else {
1544                    let partition = next_round_robin;
1545                    next_round_robin = (next_round_robin + 1) % partition_count.max(1);
1546                    partition
1547                };
1548
1549                let partition_state = &mut partitions[partition];
1550
1551                if self.build_streams_on_first_read && !partition_state.built {
1552                    let stream = FileStreamBuilder::new(&config)
1553                        .with_partition(partition)
1554                        .with_shared_work_source(shared_work_source.clone())
1555                        .with_morselizer(Box::new(self.morselizer.clone()))
1556                        .with_metrics(&metrics_set)
1557                        .build()?;
1558                    partition_state.set_stream(stream);
1559                }
1560
1561                let Some(stream) = partition_state.stream.as_mut() else {
1562                    continue;
1563                };
1564
1565                match stream.next().await {
1566                    Some(result) => partition_state.push_output(format_result(result)),
1567                    None => partition_state.finish(),
1568                }
1569            }
1570
1571            let output_text = if partition_count == 1 {
1572                format!(
1573                    "----- Output Stream -----\n{}",
1574                    partitions[0].output.join("\n")
1575                )
1576            } else {
1577                partitions
1578                    .into_iter()
1579                    .enumerate()
1580                    .map(|(partition, state)| {
1581                        format!(
1582                            "----- Partition {} -----\n{}",
1583                            partition,
1584                            state.output.join("\n")
1585                        )
1586                    })
1587                    .collect::<Vec<_>>()
1588                    .join("\n")
1589            };
1590
1591            let file_stream_events = if self.file_stream_events {
1592                observer.format_events()
1593            } else {
1594                "(omitted due to with_file_stream_events(false))".to_string()
1595            };
1596
1597            Ok(format!(
1598                "{output_text}\n----- File Stream Events -----\n{file_stream_events}",
1599            ))
1600        }
1601
1602        /// Returns the number of configured partitions, including empty ones
1603        /// that appear only in the explicit read schedule.
1604        fn num_partitions(&self) -> usize {
1605            self.partition_files
1606                .keys()
1607                .map(|partition| partition.0 + 1)
1608                .chain(self.reads.iter().map(|partition| partition.0 + 1))
1609                .max()
1610                .unwrap_or(1)
1611        }
1612
1613        /// Builds a `FileScanConfig` covering every configured partition.
1614        fn test_config(&self) -> FileScanConfig {
1615            let file_groups = (0..self.num_partitions())
1616                .map(|partition| {
1617                    self.partition_files
1618                        .get(&PartitionId(partition))
1619                        .into_iter()
1620                        .flat_map(|files| files.iter())
1621                        .map(|name| PartitionedFile::new(name, 10))
1622                        .collect::<Vec<_>>()
1623                        .into()
1624                })
1625                .collect::<Vec<_>>();
1626
1627            let table_schema = TableSchema::new(
1628                Arc::new(Schema::new(vec![Field::new("i", DataType::Int32, false)])),
1629                vec![],
1630            );
1631            FileScanConfigBuilder::new(
1632                ObjectStoreUrl::parse("test:///").unwrap(),
1633                Arc::new(MockSource::new(table_schema)),
1634            )
1635            .with_file_groups(file_groups)
1636            .with_limit(self.limit)
1637            .with_preserve_order(self.preserve_order)
1638            .with_partitioned_by_file_group(self.partitioned_by_file_group)
1639            .build()
1640        }
1641    }
1642
1643    /// Formats one stream poll result into a stable snapshot line.
1644    fn format_result(result: Result<RecordBatch>) -> String {
1645        match result {
1646            Ok(batch) => {
1647                let col = batch.column(0).as_primitive::<Int32Type>();
1648                let batch_id = col.value(0);
1649                format!("Batch: {batch_id}")
1650            }
1651            Err(e) => {
1652                // Pull the actual message for external errors rather than
1653                // relying on DataFusionError formatting, which changes if
1654                // backtraces are enabled, etc.
1655                let message = if let DataFusionError::External(generic) = e {
1656                    generic.to_string()
1657                } else {
1658                    e.to_string()
1659                };
1660                format!("Error: {message}")
1661            }
1662        }
1663    }
1664
1665    async fn drain_stream_output(stream: FileStream) -> Result<String> {
1666        let output = stream
1667            .collect::<Vec<_>>()
1668            .await
1669            .into_iter()
1670            .map(|result| result.map(|batch| format_result(Ok(batch))))
1671            .collect::<Result<Vec<_>>>()?;
1672        Ok(output.join("\n"))
1673    }
1674
1675    fn metric_count(metrics: &ExecutionPlanMetricsSet, name: &str) -> usize {
1676        metrics
1677            .clone_inner()
1678            .sum_by_name(name)
1679            .unwrap_or_else(|| panic!("missing metric: {name}"))
1680            .as_usize()
1681    }
1682
1683    /// Test-only state for one stream partition in [`FileStreamMorselTest`].
1684    struct PartitionState {
1685        /// Whether the `FileStream` for this partition has been built yet.
1686        built: bool,
1687        /// The live stream, if this partition has not finished yet.
1688        stream: Option<FileStream>,
1689        /// Snapshot lines produced by this partition.
1690        output: Vec<String>,
1691    }
1692
1693    impl PartitionState {
1694        /// Create an unbuilt partition with no output yet.
1695        fn new() -> Self {
1696            Self {
1697                built: false,
1698                stream: None,
1699                output: vec![],
1700            }
1701        }
1702
1703        /// Returns true if this partition might still produce output.
1704        fn is_active(&self) -> bool {
1705            !self.built || self.stream.is_some()
1706        }
1707
1708        /// Records that this partition's stream has been built.
1709        fn set_stream(&mut self, stream: FileStream) {
1710            self.stream = Some(stream);
1711            self.built = true;
1712        }
1713
1714        /// Records one formatted output line for this partition.
1715        fn push_output(&mut self, line: String) {
1716            self.output.push(line);
1717        }
1718
1719        /// Marks this partition as finished.
1720        fn finish(&mut self) {
1721            self.push_output("Done".to_string());
1722            self.stream = None;
1723        }
1724    }
1725}