| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| //! Parquet output format |
| |
| use crate::statistics::WriteStatistics; |
| use arrow::datatypes::SchemaRef; |
| use futures::StreamExt; |
| use log::debug; |
| use parquet::arrow::arrow_writer::{compute_leaves, get_column_writers, ArrowColumnChunk}; |
| use parquet::arrow::ArrowSchemaConverter; |
| use parquet::basic::Compression; |
| use parquet::file::properties::WriterProperties; |
| use parquet::file::writer::SerializedFileWriter; |
| use parquet::schema::types::SchemaDescPtr; |
| use spatialbench_arrow::RecordBatchIterator; |
| use std::io; |
| use std::io::Write; |
| use std::sync::Arc; |
| use tokio::sync::mpsc::{Receiver, Sender}; |
| |
| /// Finalize a writer after all Parquet data has been written. |
| /// |
| /// This is called from the async context (outside `spawn_blocking`) so |
| /// that implementations like [`S3Writer`](crate::s3_writer::S3Writer) can |
| /// `.await` their upload without competing with the tokio runtime for |
| /// threads — avoiding deadlocks under concurrent plans. |
| /// |
| /// For local files and stdout the implementation is trivially synchronous. |
| pub trait AsyncFinalize: Write + Send + 'static { |
| /// Finalize the writer and return the total bytes written. |
| fn finalize(self) -> impl std::future::Future<Output = Result<usize, io::Error>> + Send; |
| } |
| |
| /// Converts a set of RecordBatchIterators into a Parquet file |
| /// |
| /// Uses num_threads to generate the data in parallel |
| /// |
| /// Note the input is an iterator of [`RecordBatchIterator`]; The batches |
| /// produced by each iterator is encoded as its own row group. |
| pub async fn generate_parquet<W: AsyncFinalize, I>( |
| writer: W, |
| iter_iter: I, |
| num_threads: usize, |
| parquet_compression: Compression, |
| ) -> Result<(), io::Error> |
| where |
| I: Iterator<Item: RecordBatchIterator> + 'static, |
| { |
| debug!( |
| "Generating Parquet with {num_threads} threads, using {parquet_compression} compression" |
| ); |
| // Based on example in https://docs.rs/parquet/latest/parquet/arrow/arrow_writer/struct.ArrowColumnWriter.html |
| let mut iter_iter = iter_iter.peekable(); |
| |
| // get schema from the first iterator |
| let Some(first_iter) = iter_iter.peek() else { |
| return Ok(()); // no data shrug |
| }; |
| let schema = Arc::clone(first_iter.schema()); |
| |
| // Compute the parquet schema |
| let writer_properties = WriterProperties::builder() |
| .set_compression(parquet_compression) |
| .build(); |
| let writer_properties = Arc::new(writer_properties); |
| let parquet_schema = Arc::new( |
| ArrowSchemaConverter::new() |
| .with_coerce_types(writer_properties.coerce_types()) |
| .convert(&schema) |
| .unwrap(), |
| ); |
| |
| // create a stream that computes the data for each row group |
| let mut row_group_stream = futures::stream::iter(iter_iter) |
| .map(async |iter| { |
| let parquet_schema = Arc::clone(&parquet_schema); |
| let writer_properties = Arc::clone(&writer_properties); |
| let schema = Arc::clone(&schema); |
| // run on a separate thread |
| tokio::task::spawn(async move { |
| encode_row_group(parquet_schema, writer_properties, schema, iter) |
| }) |
| .await |
| .expect("Inner task panicked") |
| }) |
| .buffered(num_threads); // generate row groups in parallel |
| |
| let mut statistics = WriteStatistics::new("row groups"); |
| |
| // A blocking task that writes the row groups to the file |
| // done in a blocking task to avoid having a thread waiting on IO |
| // Now, read each completed row group and write it to the file |
| let root_schema = parquet_schema.root_schema_ptr(); |
| let writer_properties_captured = Arc::clone(&writer_properties); |
| let (tx, mut rx): ( |
| Sender<Vec<ArrowColumnChunk>>, |
| Receiver<Vec<ArrowColumnChunk>>, |
| ) = tokio::sync::mpsc::channel(num_threads); |
| let writer_task = tokio::task::spawn_blocking(move || { |
| // Create parquet writer |
| let mut writer = SerializedFileWriter::new(writer, root_schema, writer_properties_captured) |
| .map_err(io::Error::from)?; |
| |
| while let Some(chunks) = rx.blocking_recv() { |
| // Start row group |
| let mut row_group_writer = writer.next_row_group().map_err(io::Error::from)?; |
| |
| // Slap the chunks into the row group |
| for chunk in chunks { |
| chunk |
| .append_to_row_group(&mut row_group_writer) |
| .map_err(io::Error::from)?; |
| } |
| row_group_writer.close().map_err(io::Error::from)?; |
| statistics.increment_chunks(1); |
| } |
| let inner = writer.into_inner().map_err(io::Error::from)?; |
| Ok((inner, statistics)) as Result<(W, WriteStatistics), io::Error> |
| }); |
| |
| // now, drive the input stream and send results to the writer task |
| while let Some(chunks) = row_group_stream.next().await { |
| // send the chunks to the writer task |
| if let Err(e) = tx.send(chunks).await { |
| debug!("Error sending chunks to writer: {e}"); |
| break; // stop early |
| } |
| } |
| // signal the writer task that we are done |
| drop(tx); |
| |
| // Wait for the blocking writer task to return the underlying writer |
| let (inner, mut statistics) = writer_task.await??; |
| |
| // Finalize in the async context so S3 uploads can .await without |
| // competing for tokio runtime threads (prevents deadlock under |
| // concurrent plans). |
| let size = inner.finalize().await?; |
| statistics.increment_bytes(size); |
| |
| Ok(()) |
| } |
| |
| /// Creates the data for a particular row group |
| /// |
| /// Note at the moment it does not use multiple tasks/threads but it could |
| /// potentially encode multiple columns with different threads . |
| /// |
| /// Returns an array of [`ArrowColumnChunk`] |
| fn encode_row_group<I>( |
| parquet_schema: SchemaDescPtr, |
| writer_properties: Arc<WriterProperties>, |
| schema: SchemaRef, |
| iter: I, |
| ) -> Vec<ArrowColumnChunk> |
| where |
| I: RecordBatchIterator, |
| { |
| // Create writers for each of the leaf columns |
| let mut col_writers = get_column_writers(&parquet_schema, &writer_properties, &schema).unwrap(); |
| |
| // generate the data and send it to the tasks (via the sender channels) |
| for batch in iter { |
| let columns = batch.columns().iter(); |
| let col_writers = col_writers.iter_mut(); |
| let fields = schema.fields().iter(); |
| |
| for ((col_writer, field), arr) in col_writers.zip(fields).zip(columns) { |
| for leaves in compute_leaves(field.as_ref(), arr).unwrap() { |
| col_writer.write(&leaves).unwrap(); |
| } |
| } |
| } |
| // finish the writers and create the column chunks |
| col_writers |
| .into_iter() |
| .map(|col_writer| col_writer.close().unwrap()) |
| .collect() |
| } |