Skip to main content

datafusion_datasource_arrow/
source.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//! Execution plan for reading Arrow IPC files
19//!
20//! # Naming Note
21//!
22//! The naming in this module can be confusing:
23//! - `ArrowFileOpener` handles the Arrow IPC **file format**
24//!   (with footer, supports parallel reading)
25//! - `ArrowStreamFileOpener` handles the Arrow IPC **stream format**
26//!   (without footer, sequential only)
27//! - `ArrowSource` is the unified `FileSource` implementation that uses either opener
28//!   depending on the format specified at construction
29//!
30//! Despite the name "ArrowStreamFileOpener", it still reads from files - the "Stream"
31//! refers to the Arrow IPC stream format, not streaming I/O. Both formats can be stored
32//! in files on disk or object storage.
33
34use std::io::Cursor;
35use std::sync::Arc;
36
37use datafusion_datasource::{TableSchema, as_file_source};
38
39use arrow::buffer::Buffer;
40use arrow::ipc::reader::{FileDecoder, FileReader, StreamReader};
41use datafusion_common::error::Result;
42use datafusion_common::exec_datafusion_err;
43use datafusion_common::tree_node::TreeNodeRecursion;
44use datafusion_datasource::PartitionedFile;
45use datafusion_datasource::file::FileSource;
46use datafusion_datasource::file_scan_config::FileScanConfig;
47use datafusion_datasource::projection::{ProjectionOpener, SplitProjection};
48use datafusion_physical_expr_common::sort_expr::LexOrdering;
49use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
50use datafusion_physical_plan::projection::ProjectionExprs;
51
52use datafusion_datasource::file_stream::FileOpenFuture;
53use datafusion_datasource::file_stream::FileOpener;
54use futures::StreamExt;
55use itertools::Itertools;
56use object_store::{GetOptions, GetRange, GetResultPayload, ObjectStore, ObjectStoreExt};
57
58/// Enum indicating which Arrow IPC format to use
59#[derive(Clone, Copy, Debug)]
60enum ArrowFormat {
61    /// Arrow IPC file format (with footer, supports parallel reading)
62    File,
63    /// Arrow IPC stream format (without footer, sequential only)
64    Stream,
65}
66
67/// `FileOpener` for Arrow IPC stream format. Supports only sequential reading.
68pub(crate) struct ArrowStreamFileOpener {
69    object_store: Arc<dyn ObjectStore>,
70    projection: Option<Vec<usize>>,
71}
72
73impl FileOpener for ArrowStreamFileOpener {
74    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
75        if partitioned_file.range.is_some() {
76            return Err(exec_datafusion_err!(
77                "ArrowStreamFileOpener does not support range-based reading"
78            ));
79        }
80        let object_store = Arc::clone(&self.object_store);
81        let projection = self.projection.clone();
82
83        Ok(Box::pin(async move {
84            let r = object_store
85                .get(&partitioned_file.object_meta.location)
86                .await?;
87
88            let stream = match r.payload {
89                #[cfg(not(target_arch = "wasm32"))]
90                GetResultPayload::File(file, _) => futures::stream::iter(
91                    StreamReader::try_new(file.try_clone()?, projection.clone())?,
92                )
93                .map(|r| r.map_err(Into::into))
94                .boxed(),
95                GetResultPayload::Stream(_) => {
96                    let bytes = r.bytes().await?;
97                    let cursor = Cursor::new(bytes);
98                    futures::stream::iter(StreamReader::try_new(
99                        cursor,
100                        projection.clone(),
101                    )?)
102                    .map(|r| r.map_err(Into::into))
103                    .boxed()
104                }
105            };
106
107            Ok(stream)
108        }))
109    }
110}
111
112/// `FileOpener` for Arrow IPC file format. Supports range-based parallel reading.
113pub(crate) struct ArrowFileOpener {
114    object_store: Arc<dyn ObjectStore>,
115    projection: Option<Vec<usize>>,
116}
117
118impl FileOpener for ArrowFileOpener {
119    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
120        let object_store = Arc::clone(&self.object_store);
121        let projection = self.projection.clone();
122
123        Ok(Box::pin(async move {
124            let range = partitioned_file.range.clone();
125            match range {
126                None => {
127                    let r = object_store
128                        .get(&partitioned_file.object_meta.location)
129                        .await?;
130                    let stream = match r.payload {
131                        #[cfg(not(target_arch = "wasm32"))]
132                        GetResultPayload::File(file, _) => futures::stream::iter(
133                            FileReader::try_new(file.try_clone()?, projection.clone())?,
134                        )
135                        .map(|r| r.map_err(Into::into))
136                        .boxed(),
137                        GetResultPayload::Stream(_) => {
138                            let bytes = r.bytes().await?;
139                            let cursor = Cursor::new(bytes);
140                            futures::stream::iter(FileReader::try_new(
141                                cursor,
142                                projection.clone(),
143                            )?)
144                            .map(|r| r.map_err(Into::into))
145                            .boxed()
146                        }
147                    };
148
149                    Ok(stream)
150                }
151                Some(range) => {
152                    // range is not none, the file maybe split into multiple parts to scan in parallel
153                    // get footer_len firstly
154                    let get_option = GetOptions {
155                        range: Some(GetRange::Suffix(10)),
156                        ..Default::default()
157                    };
158                    let get_result = object_store
159                        .get_opts(&partitioned_file.object_meta.location, get_option)
160                        .await?;
161                    let footer_len_buf = get_result.bytes().await?;
162                    let footer_len = arrow_ipc::reader::read_footer_length(
163                        footer_len_buf[..].try_into().unwrap(),
164                    )?;
165                    // read footer according to footer_len
166                    let get_option = GetOptions {
167                        range: Some(GetRange::Suffix(10 + (footer_len as u64))),
168                        ..Default::default()
169                    };
170                    let get_result = object_store
171                        .get_opts(&partitioned_file.object_meta.location, get_option)
172                        .await?;
173                    let footer_buf = get_result.bytes().await?;
174                    let footer = arrow_ipc::root_as_footer(
175                        footer_buf[..footer_len].try_into().unwrap(),
176                    )
177                    .map_err(|err| {
178                        exec_datafusion_err!("Unable to get root as footer: {err:?}")
179                    })?;
180                    // build decoder according to footer & projection
181                    let schema =
182                        arrow_ipc::convert::fb_to_schema(footer.schema().unwrap());
183                    let mut decoder = FileDecoder::new(schema.into(), footer.version());
184                    if let Some(projection) = projection {
185                        decoder = decoder.with_projection(projection);
186                    }
187                    let dict_ranges = footer
188                        .dictionaries()
189                        .iter()
190                        .flatten()
191                        .map(|block| {
192                            let block_len =
193                                block.bodyLength() as u64 + block.metaDataLength() as u64;
194                            let block_offset = block.offset() as u64;
195                            block_offset..block_offset + block_len
196                        })
197                        .collect_vec();
198                    let dict_results = object_store
199                        .get_ranges(&partitioned_file.object_meta.location, &dict_ranges)
200                        .await?;
201                    for (dict_block, dict_result) in
202                        footer.dictionaries().iter().flatten().zip(dict_results)
203                    {
204                        decoder
205                            .read_dictionary(dict_block, &Buffer::from(dict_result))?;
206                    }
207
208                    // filter recordbatches according to range
209                    let recordbatches = footer
210                        .recordBatches()
211                        .iter()
212                        .flatten()
213                        .filter(|block| {
214                            let block_offset = block.offset() as u64;
215                            block_offset >= range.start as u64
216                                && block_offset < range.end as u64
217                        })
218                        .copied()
219                        .collect_vec();
220
221                    let recordbatch_ranges = recordbatches
222                        .iter()
223                        .map(|block| {
224                            let block_len =
225                                block.bodyLength() as u64 + block.metaDataLength() as u64;
226                            let block_offset = block.offset() as u64;
227                            block_offset..block_offset + block_len
228                        })
229                        .collect_vec();
230
231                    let recordbatch_results = object_store
232                        .get_ranges(
233                            &partitioned_file.object_meta.location,
234                            &recordbatch_ranges,
235                        )
236                        .await?;
237
238                    let stream = futures::stream::iter(
239                        recordbatches
240                            .into_iter()
241                            .zip(recordbatch_results)
242                            .filter_map(move |(block, data)| {
243                                decoder
244                                    .read_record_batch(&block, &Buffer::from(data))
245                                    .transpose()
246                            }),
247                    )
248                    .map(|r| r.map_err(Into::into))
249                    .boxed();
250
251                    Ok(stream)
252                }
253            }
254        }))
255    }
256}
257
258/// `FileSource` for both Arrow IPC file and stream formats
259#[derive(Clone)]
260pub struct ArrowSource {
261    format: ArrowFormat,
262    metrics: ExecutionPlanMetricsSet,
263    projection: SplitProjection,
264    table_schema: TableSchema,
265}
266
267impl ArrowSource {
268    /// Creates an [`ArrowSource`] for file format
269    pub fn new_file_source(table_schema: impl Into<TableSchema>) -> Self {
270        let table_schema = table_schema.into();
271        Self {
272            format: ArrowFormat::File,
273            metrics: ExecutionPlanMetricsSet::new(),
274            projection: SplitProjection::unprojected(&table_schema),
275            table_schema,
276        }
277    }
278
279    /// Creates an [`ArrowSource`] for stream format
280    pub fn new_stream_file_source(table_schema: impl Into<TableSchema>) -> Self {
281        let table_schema = table_schema.into();
282        Self {
283            format: ArrowFormat::Stream,
284            metrics: ExecutionPlanMetricsSet::new(),
285            projection: SplitProjection::unprojected(&table_schema),
286            table_schema,
287        }
288    }
289}
290
291impl FileSource for ArrowSource {
292    fn create_file_opener(
293        &self,
294        object_store: Arc<dyn ObjectStore>,
295        _base_config: &FileScanConfig,
296        _partition: usize,
297    ) -> Result<Arc<dyn FileOpener>> {
298        let split_projection = self.projection.clone();
299
300        let opener: Arc<dyn FileOpener> = match self.format {
301            ArrowFormat::File => Arc::new(ArrowFileOpener {
302                object_store,
303                projection: Some(split_projection.file_indices.clone()),
304            }),
305            ArrowFormat::Stream => Arc::new(ArrowStreamFileOpener {
306                object_store,
307                projection: Some(split_projection.file_indices.clone()),
308            }),
309        };
310        ProjectionOpener::try_new(
311            split_projection,
312            opener,
313            self.table_schema.file_schema(),
314        )
315    }
316
317    fn with_batch_size(&self, _batch_size: usize) -> Arc<dyn FileSource> {
318        Arc::new(Self { ..self.clone() })
319    }
320
321    fn metrics(&self) -> &ExecutionPlanMetricsSet {
322        &self.metrics
323    }
324
325    fn file_type(&self) -> &str {
326        match self.format {
327            ArrowFormat::File => "arrow",
328            ArrowFormat::Stream => "arrow_stream",
329        }
330    }
331
332    fn repartitioned(
333        &self,
334        target_partitions: usize,
335        repartition_file_min_size: usize,
336        output_ordering: Option<LexOrdering>,
337        config: &FileScanConfig,
338    ) -> Result<Option<FileScanConfig>> {
339        match self.format {
340            ArrowFormat::Stream => {
341                // The Arrow IPC stream format doesn't support range-based parallel reading
342                // because it lacks a footer with the information that would be needed to
343                // make range-based parallel reading practical. Without the data in the
344                // footer you would either need to read the entire file and record the
345                // offsets of the record batches and dictionaries, essentially recreating
346                // the footer's contents, or else each partition would need to read the
347                // entire file up to the correct offset which is a lot of duplicate I/O.
348                // We're opting to avoid that entirely by only acting on a single partition
349                // and reading sequentially.
350                Ok(None)
351            }
352            ArrowFormat::File => {
353                // Use the default trait implementation logic for file format
354                use datafusion_datasource::file_groups::FileGroupPartitioner;
355
356                if config.file_compression_type.is_compressed() {
357                    return Ok(None);
358                }
359
360                let repartitioned_file_groups_option = FileGroupPartitioner::new()
361                    .with_target_partitions(target_partitions)
362                    .with_repartition_file_min_size(repartition_file_min_size)
363                    .with_preserve_order_within_groups(output_ordering.is_some())
364                    .repartition_file_groups(&config.file_groups);
365
366                if let Some(repartitioned_file_groups) = repartitioned_file_groups_option
367                {
368                    let mut source = config.clone();
369                    source.file_groups = repartitioned_file_groups;
370                    return Ok(Some(source));
371                }
372                Ok(None)
373            }
374        }
375    }
376
377    fn table_schema(&self) -> &TableSchema {
378        &self.table_schema
379    }
380
381    fn try_pushdown_projection(
382        &self,
383        projection: &ProjectionExprs,
384    ) -> Result<Option<Arc<dyn FileSource>>> {
385        let mut source = self.clone();
386        source.projection = SplitProjection::new(
387            self.table_schema().file_schema(),
388            &source.projection.source.try_merge(projection)?,
389        );
390        Ok(Some(Arc::new(source)))
391    }
392
393    fn projection(&self) -> Option<&ProjectionExprs> {
394        Some(&self.projection.source)
395    }
396
397    fn apply_expressions(
398        &self,
399        f: &mut dyn FnMut(
400            &Arc<dyn datafusion_physical_plan::PhysicalExpr>,
401        ) -> Result<TreeNodeRecursion>,
402    ) -> Result<TreeNodeRecursion> {
403        datafusion_physical_plan::apply_expression_roots(self.projection.source.iter(), f)
404    }
405
406    /// Emit an `ArrowScan` node wrapping the shared base config.
407    ///
408    /// Decoding defaults to the IPC file format because protobuf does not
409    /// distinguish it from the IPC stream format.
410    #[cfg(feature = "proto")]
411    fn try_to_proto(
412        &self,
413        base: &FileScanConfig,
414        ctx: &datafusion_physical_plan::proto::ExecutionPlanEncodeCtx<'_>,
415    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
416        use datafusion_proto_models::protobuf;
417        use protobuf::physical_plan_node::PhysicalPlanType;
418
419        Ok(Some(protobuf::PhysicalPlanNode {
420            physical_plan_type: Some(PhysicalPlanType::ArrowScan(
421                protobuf::ArrowScanExecNode {
422                    base_conf: Some(base.try_to_proto(ctx)?),
423                },
424            )),
425        }))
426    }
427}
428
429#[cfg(feature = "proto")]
430impl ArrowSource {
431    /// Reconstructs a `DataSourceExec` from a protobuf `ArrowScan`.
432    ///
433    /// Defaults to the IPC file format because protobuf does not distinguish it
434    /// from the IPC stream format.
435    pub fn try_from_proto(
436        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
437        ctx: &datafusion_physical_plan::proto::ExecutionPlanDecodeCtx<'_>,
438    ) -> Result<Arc<dyn datafusion_physical_plan::ExecutionPlan>> {
439        use datafusion_datasource::file_scan_config::FileScanConfig;
440        use datafusion_datasource::source::DataSourceExec;
441        use datafusion_proto_models::protobuf;
442
443        let scan = match &node.physical_plan_type {
444            Some(protobuf::physical_plan_node::PhysicalPlanType::ArrowScan(scan)) => scan,
445            _ => {
446                return datafusion_common::internal_err!(
447                    "PhysicalPlanNode is not an ArrowScan"
448                );
449            }
450        };
451
452        let base_conf = scan.base_conf.as_ref().ok_or_else(|| {
453            datafusion_common::internal_datafusion_err!(
454                "ArrowScanExecNode is missing required field 'base_conf'"
455            )
456        })?;
457
458        let table_schema = FileScanConfig::parse_table_schema_from_proto(base_conf)?;
459        let source = Arc::new(ArrowSource::new_file_source(table_schema));
460        let scan_conf = FileScanConfig::try_from_proto(base_conf, ctx, source)?;
461        Ok(DataSourceExec::from_data_source(scan_conf))
462    }
463}
464
465/// `FileOpener` wrapper for both Arrow IPC file and stream formats
466pub struct ArrowOpener {
467    pub inner: Arc<dyn FileOpener>,
468}
469
470impl FileOpener for ArrowOpener {
471    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
472        self.inner.open(partitioned_file)
473    }
474}
475
476impl ArrowOpener {
477    /// Creates a new [`ArrowOpener`]
478    pub fn new(inner: Arc<dyn FileOpener>) -> Self {
479        Self { inner }
480    }
481
482    pub fn new_file_opener(
483        object_store: Arc<dyn ObjectStore>,
484        projection: Option<Vec<usize>>,
485    ) -> Self {
486        Self {
487            inner: Arc::new(ArrowFileOpener {
488                object_store,
489                projection,
490            }),
491        }
492    }
493
494    pub fn new_stream_file_opener(
495        object_store: Arc<dyn ObjectStore>,
496        projection: Option<Vec<usize>>,
497    ) -> Self {
498        Self {
499            inner: Arc::new(ArrowStreamFileOpener {
500                object_store,
501                projection,
502            }),
503        }
504    }
505}
506
507impl From<ArrowSource> for Arc<dyn FileSource> {
508    fn from(source: ArrowSource) -> Self {
509        as_file_source(source)
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use std::{fs::File, io::Read};
516
517    use arrow::datatypes::{DataType, Field, Schema};
518    use arrow_ipc::reader::{FileReader, StreamReader};
519    use bytes::Bytes;
520    use datafusion_datasource::file_scan_config::FileScanConfigBuilder;
521    use datafusion_execution::object_store::ObjectStoreUrl;
522    use object_store::memory::InMemory;
523
524    use super::*;
525
526    #[tokio::test]
527    async fn test_file_opener_without_ranges() -> Result<()> {
528        for filename in ["example.arrow", "example_stream.arrow"] {
529            let path = format!("tests/data/{filename}");
530            let path_str = path.as_str();
531            let mut file = File::open(path_str)?;
532            let file_size = file.metadata()?.len();
533
534            let mut buffer = Vec::new();
535            file.read_to_end(&mut buffer)?;
536            let bytes = Bytes::from(buffer);
537
538            let object_store = Arc::new(InMemory::new());
539            let partitioned_file = PartitionedFile::new(filename, file_size);
540            object_store
541                .put(&partitioned_file.object_meta.location, bytes.into())
542                .await?;
543
544            let schema = match FileReader::try_new(File::open(path_str)?, None) {
545                Ok(reader) => reader.schema(),
546                Err(_) => StreamReader::try_new(File::open(path_str)?, None)?.schema(),
547            };
548
549            let source: Arc<dyn FileSource> = if filename.contains("stream") {
550                Arc::new(ArrowSource::new_stream_file_source(schema))
551            } else {
552                Arc::new(ArrowSource::new_file_source(schema))
553            };
554
555            let scan_config = FileScanConfigBuilder::new(
556                ObjectStoreUrl::local_filesystem(),
557                source.clone(),
558            )
559            .build();
560
561            let file_opener = source.create_file_opener(object_store, &scan_config, 0)?;
562            let mut stream = file_opener.open(partitioned_file)?.await?;
563
564            assert!(stream.next().await.is_some());
565        }
566
567        Ok(())
568    }
569
570    #[tokio::test]
571    async fn test_file_opener_with_ranges() -> Result<()> {
572        let filename = "example.arrow";
573        let path = format!("tests/data/{filename}");
574        let path_str = path.as_str();
575        let mut file = File::open(path_str)?;
576        let file_size = file.metadata()?.len();
577
578        let mut buffer = Vec::new();
579        file.read_to_end(&mut buffer)?;
580        let bytes = Bytes::from(buffer);
581
582        let object_store = Arc::new(InMemory::new());
583        let partitioned_file = PartitionedFile::new_with_range(
584            filename.into(),
585            file_size,
586            0,
587            (file_size - 1) as i64,
588        );
589        object_store
590            .put(&partitioned_file.object_meta.location, bytes.into())
591            .await?;
592
593        let schema = FileReader::try_new(File::open(path_str)?, None)?.schema();
594
595        let source = Arc::new(ArrowSource::new_file_source(schema));
596
597        let scan_config = FileScanConfigBuilder::new(
598            ObjectStoreUrl::local_filesystem(),
599            source.clone(),
600        )
601        .build();
602
603        let file_opener = source.create_file_opener(object_store, &scan_config, 0)?;
604        let mut stream = file_opener.open(partitioned_file)?.await?;
605
606        assert!(stream.next().await.is_some());
607
608        Ok(())
609    }
610
611    #[tokio::test]
612    async fn test_stream_opener_errors_with_ranges() -> Result<()> {
613        let filename = "example_stream.arrow";
614        let path = format!("tests/data/{filename}");
615        let path_str = path.as_str();
616        let mut file = File::open(path_str)?;
617        let file_size = file.metadata()?.len();
618
619        let mut buffer = Vec::new();
620        file.read_to_end(&mut buffer)?;
621        let bytes = Bytes::from(buffer);
622
623        let object_store = Arc::new(InMemory::new());
624        let partitioned_file = PartitionedFile::new_with_range(
625            filename.into(),
626            file_size,
627            0,
628            (file_size - 1) as i64,
629        );
630        object_store
631            .put(&partitioned_file.object_meta.location, bytes.into())
632            .await?;
633
634        let schema = StreamReader::try_new(File::open(path_str)?, None)?.schema();
635
636        let source = Arc::new(ArrowSource::new_stream_file_source(schema));
637
638        let scan_config = FileScanConfigBuilder::new(
639            ObjectStoreUrl::local_filesystem(),
640            source.clone(),
641        )
642        .build();
643
644        let file_opener = source.create_file_opener(object_store, &scan_config, 0)?;
645        let result = file_opener.open(partitioned_file);
646        assert!(result.is_err());
647
648        Ok(())
649    }
650
651    #[tokio::test]
652    async fn test_arrow_stream_repartitioning_not_supported() -> Result<()> {
653        let schema =
654            Arc::new(Schema::new(vec![Field::new("f0", DataType::Int64, false)]));
655        let source = ArrowSource::new_stream_file_source(schema);
656
657        let config = FileScanConfigBuilder::new(
658            ObjectStoreUrl::local_filesystem(),
659            Arc::new(source.clone()) as Arc<dyn FileSource>,
660        )
661        .build();
662
663        for target_partitions in [2, 4, 8, 16] {
664            let result =
665                source.repartitioned(target_partitions, 1024 * 1024, None, &config)?;
666
667            assert!(
668                result.is_none(),
669                "Stream format should not support repartitioning with {target_partitions} partitions",
670            );
671        }
672
673        Ok(())
674    }
675
676    #[tokio::test]
677    async fn test_stream_opener_with_projection() -> Result<()> {
678        let filename = "example_stream.arrow";
679        let path = format!("tests/data/{filename}");
680        let path_str = path.as_str();
681        let mut file = File::open(path_str)?;
682        let file_size = file.metadata()?.len();
683
684        let mut buffer = Vec::new();
685        file.read_to_end(&mut buffer)?;
686        let bytes = Bytes::from(buffer);
687
688        let object_store = Arc::new(InMemory::new());
689        let partitioned_file = PartitionedFile::new(filename, file_size);
690        object_store
691            .put(&partitioned_file.object_meta.location, bytes.into())
692            .await?;
693
694        let opener = ArrowStreamFileOpener {
695            object_store,
696            projection: Some(vec![0]), // just the first column
697        };
698
699        let mut stream = opener.open(partitioned_file)?.await?;
700
701        if let Some(batch) = stream.next().await {
702            let batch = batch?;
703            assert_eq!(
704                batch.num_columns(),
705                1,
706                "Projection should result in 1 column"
707            );
708        } else {
709            panic!("Expected at least one batch");
710        }
711
712        Ok(())
713    }
714}