Skip to main content

vortex_datafusion/persistent/
sink.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::sync::Arc;
5
6use arrow_schema::SchemaRef;
7use async_trait::async_trait;
8use datafusion_common::DataFusionError;
9use datafusion_common::Result as DFResult;
10use datafusion_common::exec_datafusion_err;
11use datafusion_common_runtime::JoinSet;
12use datafusion_common_runtime::SpawnedTask;
13use datafusion_datasource::display::FileGroupDisplay;
14use datafusion_datasource::file_sink_config::FileSink;
15use datafusion_datasource::file_sink_config::FileSinkConfig;
16use datafusion_datasource::sink::DataSink;
17use datafusion_datasource::write::demux::DemuxedStreamReceiver;
18use datafusion_datasource::write::get_writer_schema;
19use datafusion_execution::SendableRecordBatchStream;
20use datafusion_execution::TaskContext;
21use datafusion_physical_plan::DisplayAs;
22use datafusion_physical_plan::DisplayFormatType;
23use datafusion_physical_plan::metrics::MetricsSet;
24use futures::StreamExt;
25use object_store::ObjectStore;
26use object_store::path::Path;
27use tokio_stream::wrappers::ReceiverStream;
28use vortex::array::arrow::ArrowSessionExt;
29use vortex::array::stream::ArrayStreamAdapter;
30use vortex::file::WriteOptionsSessionExt;
31use vortex::file::WriteSummary;
32use vortex::io::VortexWrite;
33use vortex::io::object_store::ObjectStoreWrite;
34use vortex::session::VortexSession;
35
36/// Implements [`DataSink`] for writing Vortex files.
37pub struct VortexSink {
38    config: FileSinkConfig,
39    schema: SchemaRef,
40    session: VortexSession,
41}
42
43impl VortexSink {
44    /// Creates a new [`VortexSink`] instance.
45    pub fn new(config: FileSinkConfig, schema: SchemaRef, session: VortexSession) -> Self {
46        Self {
47            config,
48            schema,
49            session,
50        }
51    }
52}
53
54impl std::fmt::Debug for VortexSink {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("VortexSink").finish()
57    }
58}
59
60impl DisplayAs for VortexSink {
61    fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match t {
63            DisplayFormatType::Default | DisplayFormatType::Verbose => {
64                write!(f, "VortexSink(file_groups=")?;
65                FileGroupDisplay(&self.config.file_group).fmt_as(t, f)?;
66                write!(f, ")")
67            }
68            DisplayFormatType::TreeRender => {
69                write!(f, "VortexSink")
70            }
71        }
72    }
73}
74
75#[async_trait]
76impl DataSink for VortexSink {
77    fn metrics(&self) -> Option<MetricsSet> {
78        None
79    }
80
81    /// Returns the sink schema
82    fn schema(&self) -> &SchemaRef {
83        &self.schema
84    }
85
86    async fn write_all(
87        &self,
88        data: SendableRecordBatchStream,
89        context: &Arc<TaskContext>,
90    ) -> DFResult<u64> {
91        FileSink::write_all(self, data, context).await
92    }
93}
94
95#[async_trait]
96impl FileSink for VortexSink {
97    fn config(&self) -> &FileSinkConfig {
98        &self.config
99    }
100
101    async fn spawn_writer_tasks_and_join(
102        &self,
103        _context: &Arc<TaskContext>,
104        demux_task: SpawnedTask<DFResult<()>>,
105        mut file_stream_rx: DemuxedStreamReceiver,
106        object_store: Arc<dyn ObjectStore>,
107    ) -> DFResult<u64> {
108        let mut file_write_tasks: JoinSet<DFResult<(Path, WriteSummary)>> = JoinSet::new();
109        let writer_schema = get_writer_schema(&self.config);
110        let dtype = self
111            .session
112            .arrow()
113            .from_arrow_schema(&writer_schema)
114            .map_err(|e| {
115                exec_datafusion_err!("Failed to derive Vortex DType from writer schema: {e}")
116            })?;
117
118        // TODO(adamg):
119        // 1. We can probably be better at signaling how much memory we're consuming (potentially when reading too), see ParquetSink::spawn_writer_tasks_and_join.
120        while let Some((path, rx)) = file_stream_rx.recv().await {
121            let session = self.session.clone();
122            let object_store = Arc::clone(&object_store);
123
124            // We need to spawn work because there's a dependency between the different files. If one file has too many batches buffered,
125            // the demux task might deadlock itself.
126            let arrow_session = session.clone();
127            let import_schema = Arc::clone(&writer_schema);
128            let dtype = dtype.clone();
129            file_write_tasks.spawn(async move {
130                let stream = ReceiverStream::new(rx).map(move |rb| {
131                    arrow_session
132                        .arrow()
133                        .from_arrow_record_batch(rb, &import_schema)
134                });
135
136                let stream_adapter = ArrayStreamAdapter::new(dtype, stream);
137
138                let mut object_writer = ObjectStoreWrite::new(object_store, &path)
139                    .await
140                    .map_err(|e| exec_datafusion_err!("Failed to create ObjectStoreWrite: {e}"))?;
141
142                let summary = session
143                    .write_options()
144                    .write(&mut object_writer, stream_adapter)
145                    .await
146                    .map_err(|e| exec_datafusion_err!("Failed to write Vortex file: {e}"))?;
147
148                object_writer
149                    .shutdown()
150                    .await
151                    .map_err(|e| exec_datafusion_err!("Failed to shutdown Vortex writer: {e}"))?;
152
153                Ok((path, summary))
154            });
155        }
156
157        let mut row_count = 0;
158
159        while let Some(result) = file_write_tasks.join_next().await {
160            match result {
161                Ok(r) => {
162                    let (path, summary) = r?;
163
164                    row_count += summary.row_count();
165
166                    tracing::info!(path = %path, "Successfully written file");
167                }
168                Err(e) => {
169                    if e.is_panic() {
170                        std::panic::resume_unwind(e.into_panic());
171                    } else {
172                        unreachable!();
173                    }
174                }
175            }
176        }
177
178        demux_task
179            .join_unwind()
180            .await
181            .map_err(|e| DataFusionError::ExecutionJoin(Box::new(e)))??;
182
183        Ok(row_count)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use std::sync::Arc;
190
191    use arrow_schema::DataType;
192    use arrow_schema::Field;
193    use arrow_schema::Schema;
194    use datafusion::arrow::array::Int8Array;
195    use datafusion::arrow::array::Int64Array;
196    use datafusion::arrow::array::RecordBatch;
197    use datafusion::assert_batches_sorted_eq;
198    use datafusion::datasource::DefaultTableSource;
199    use datafusion::logical_expr::Expr;
200    use datafusion::logical_expr::LogicalPlan;
201    use datafusion::logical_expr::LogicalPlanBuilder;
202    use datafusion::logical_expr::Values;
203    use datafusion::logical_expr::dml::InsertOp;
204    use datafusion_common::ScalarValue;
205    use datafusion_datasource::TableSchema;
206    use datafusion_datasource::file_format::format_as_file_type;
207    use datafusion_datasource::file_groups::FileGroup;
208    use datafusion_datasource::file_sink_config::FileOutputMode;
209    use datafusion_datasource::sink::DataSinkExec;
210    use datafusion_execution::object_store::ObjectStoreUrl;
211    use datafusion_physical_plan::DefaultDisplay;
212    use datafusion_physical_plan::VerboseDisplay;
213    use datafusion_physical_plan::display::DisplayableExecutionPlan;
214    use datafusion_physical_plan::empty::EmptyExec;
215    use futures::TryStreamExt;
216    use rstest::rstest;
217    use vortex::file::VORTEX_FILE_EXTENSION;
218    use vortex::session::VortexSession;
219
220    use super::*;
221    use crate::common_tests::TestSessionContext;
222    use crate::persistent::VortexFormatFactory;
223
224    #[tokio::test]
225    async fn test_insert_into_sql() -> anyhow::Result<()> {
226        let ctx = TestSessionContext::default();
227
228        ctx.session
229            .sql(
230                "CREATE EXTERNAL TABLE my_tbl \
231                    (c1 VARCHAR NOT NULL, c2 INT NOT NULL) \
232                STORED AS vortex \
233                LOCATION 'table/';",
234            )
235            .await?;
236
237        ctx.session
238            .sql("INSERT INTO my_tbl VALUES ('hello', 1), ('world', 2);")
239            .await?
240            .collect()
241            .await?;
242
243        let batches = ctx
244            .session
245            .sql("SELECT * from my_tbl")
246            .await?
247            .collect()
248            .await?;
249
250        assert_batches_sorted_eq!(
251            &[
252                "+-------+----+",
253                "| c1    | c2 |",
254                "+-------+----+",
255                "| hello | 1  |",
256                "| world | 2  |",
257                "+-------+----+",
258            ],
259            &batches
260        );
261
262        Ok(())
263    }
264
265    #[tokio::test]
266    async fn test_insert_into_logical_plan() -> anyhow::Result<()> {
267        let ctx = TestSessionContext::default();
268
269        ctx.session
270            .sql(
271                "CREATE EXTERNAL TABLE my_tbl \
272                    (c1 VARCHAR NOT NULL, c2 INT NOT NULL) \
273                STORED AS vortex \
274                LOCATION 'table/';",
275            )
276            .await?;
277
278        let my_tbl = ctx.session.table("my_tbl").await?;
279
280        // It's valuable to have two insert code paths because they actually behave slightly differently
281        let values = Values {
282            schema: Arc::new(my_tbl.schema().clone()),
283            values: vec![vec![
284                Expr::Literal(ScalarValue::new_utf8view("hello"), None),
285                Expr::Literal(42_i32.into(), None),
286            ]],
287        };
288
289        let tbl_provider = ctx.session.table_provider("my_tbl").await?;
290
291        let logical_plan = LogicalPlanBuilder::insert_into(
292            LogicalPlan::Values(values.clone()),
293            "my_tbl",
294            Arc::new(DefaultTableSource::new(Arc::clone(&tbl_provider))),
295            InsertOp::Append,
296        )?
297        .build()?;
298
299        ctx.session
300            .execute_logical_plan(logical_plan)
301            .await?
302            .collect()
303            .await?;
304
305        let batches = ctx.session.read_table(tbl_provider)?.collect().await?;
306
307        assert_batches_sorted_eq!(
308            [
309                "+-------+----+",
310                "| c1    | c2 |",
311                "+-------+----+",
312                "| hello | 42 |",
313                "+-------+----+",
314            ],
315            &batches
316        );
317
318        Ok(())
319    }
320
321    /// Reproduction by <https://github.com/vortex-data/vortex/issues/4315>.
322    #[rstest]
323    #[case(1000, 1)]
324    #[case(40_961, 4)]
325    #[case(1_000_000, 4)]
326    #[tokio::test]
327    async fn test_write_large_batch(
328        #[case] entries: usize,
329        #[case] expected_files: usize,
330    ) -> anyhow::Result<()> {
331        let ctx = TestSessionContext::default();
332
333        let data = ctx.session.read_batch(RecordBatch::try_new(
334            Arc::new(Schema::new(vec![Field::new("a", DataType::Int8, false)])),
335            vec![Arc::new(Int8Array::from(vec![0i8; entries]))],
336        )?)?;
337
338        let logical_plan = LogicalPlanBuilder::copy_to(
339            data.logical_plan().clone(),
340            "/table/".to_string(),
341            format_as_file_type(Arc::new(VortexFormatFactory::new())),
342            Default::default(),
343            vec![],
344        )?
345        .build()?;
346
347        ctx.session
348            .execute_logical_plan(logical_plan)
349            .await?
350            .collect()
351            .await?;
352
353        let result = ctx
354            .session
355            .sql("SELECT COUNT(*) as count FROM '/table/'")
356            .await?
357            .collect()
358            .await?;
359
360        assert_eq!(result.len(), 1);
361        let count_batch = &result[0];
362        assert_eq!(count_batch.num_rows(), 1);
363
364        let count_value = count_batch
365            .column(0)
366            .as_any()
367            .downcast_ref::<Int64Array>()
368            .unwrap()
369            .value(0);
370
371        assert_eq!(
372            count_value, entries as i64,
373            "Expected {} entries, but found {}",
374            entries, count_value
375        );
376
377        let all_data = ctx
378            .session
379            .sql("SELECT a FROM '/table/'")
380            .await?
381            .collect()
382            .await?;
383
384        let mut total_rows = 0;
385        for batch in all_data {
386            let col = batch
387                .column(0)
388                .as_any()
389                .downcast_ref::<Int8Array>()
390                .unwrap();
391
392            for i in 0..batch.num_rows() {
393                assert_eq!(
394                    col.value(i),
395                    0i8,
396                    "Expected value 0 at row {}, but found {}",
397                    total_rows + i,
398                    col.value(i)
399                );
400            }
401            total_rows += batch.num_rows();
402        }
403
404        assert_eq!(
405            total_rows, entries,
406            "Total rows read ({}) doesn't match expected entries ({})",
407            total_rows, entries
408        );
409
410        let file_metas = ctx
411            .store
412            .list(Some(&"/table".into()))
413            .try_collect::<Vec<_>>()
414            .await?;
415
416        assert_eq!(
417            file_metas.len(),
418            expected_files,
419            "Expected {expected_files} files for {entries} values"
420        );
421
422        Ok(())
423    }
424
425    #[tokio::test]
426    async fn test_write_partitioned() -> anyhow::Result<()> {
427        let ctx = TestSessionContext::default();
428
429        let _unused = ctx
430            .session
431            .sql(
432                "CREATE EXTERNAL TABLE my_tbl \
433                (c1 VARCHAR NOT NULL, c2 INT NOT NULL) \
434                STORED AS vortex \
435                LOCATION 'table/' \
436                PARTITIONED BY (c1);",
437            )
438            .await?;
439
440        ctx.session
441            .sql("INSERT INTO my_tbl (c1, c2) VALUES ('world', 24), ('world', 25), ('hello', 42);")
442            .await?
443            .collect()
444            .await?;
445
446        let table = ctx.session.table("my_tbl").await?;
447        assert_eq!(table.count().await?, 3);
448
449        let location = Path::parse("table/")?;
450        let file_metas = ctx
451            .store
452            .list(Some(&location))
453            .try_collect::<Vec<_>>()
454            .await?;
455
456        for meta in file_metas.into_iter() {
457            let location = meta.location;
458            assert!(
459                location.prefix_matches(&"c1=hello".into())
460                    || location.prefix_matches(&"c1=world".into())
461            );
462        }
463
464        Ok(())
465    }
466
467    #[test]
468    fn test_display_as() {
469        let session = VortexSession::empty();
470        let table_schema = TableSchema::new(Arc::new(Schema::empty()), Vec::new());
471
472        let config = FileSinkConfig {
473            original_url: "".to_owned(),
474            object_store_url: ObjectStoreUrl::local_filesystem(),
475            file_group: FileGroup::new(Vec::new()),
476            table_paths: Vec::new(),
477            output_schema: Arc::new(Schema::empty()),
478            table_partition_cols: Vec::new(),
479            insert_op: InsertOp::Overwrite,
480            keep_partition_by_columns: false,
481            file_extension: VORTEX_FILE_EXTENSION.to_owned(),
482            file_output_mode: FileOutputMode::SingleFile,
483        };
484
485        let get_sink = || VortexSink {
486            config: config.clone(),
487            schema: Arc::clone(table_schema.file_schema()),
488            session: session.clone(),
489        };
490
491        insta::assert_snapshot!(DefaultDisplay(get_sink()).to_string(), @"VortexSink(file_groups=[])");
492        insta::assert_snapshot!(VerboseDisplay(get_sink()).to_string(), @"VortexSink(file_groups=[])");
493
494        let plan = DataSinkExec::new(
495            Arc::new(EmptyExec::new(Arc::new(Schema::empty()))),
496            Arc::new(get_sink()),
497            None,
498        );
499
500        insta::assert_snapshot!(DisplayableExecutionPlan::new(&plan).tree_render().to_string(), @r"
501        ┌───────────────────────────┐
502        │        DataSinkExec       │
503        │    --------------------   │
504        │         VortexSink        │
505        └─────────────┬─────────────┘
506        ┌─────────────┴─────────────┐
507        │         EmptyExec         │
508        └───────────────────────────┘
509        ");
510    }
511}