blob: 5b8e28dd6080864d4eee8ba07d3c44486dbdaac4 [file]
// 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.
//! Low-level data file writer shared by [`TableWrite`](super::TableWrite) and
//! [`DataEvolutionPartialWriter`](super::data_evolution_writer::DataEvolutionPartialWriter).
//!
//! `DataFileWriter` streams Arrow `RecordBatch`es to Parquet files on storage,
//! handles file rolling when `target_file_size` is reached, and collects
//! [`DataFileMeta`] for the commit path.
use crate::arrow::format::{create_format_writer, FormatFileWriter};
use crate::io::FileIO;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{bucket_dir_name, DataField, DataFileMeta, EMPTY_SERIALIZED_ROW};
use crate::Result;
use arrow_array::RecordBatch;
use chrono::Utc;
use tokio::task::JoinSet;
/// Low-level writer that produces Parquet data files for a single (partition, bucket).
///
/// Batches are accumulated into a single `FormatFileWriter` that streams directly
/// to storage. When `target_file_size` is reached the current file is rolled
/// (closed in the background) and a new one is opened on the next write.
///
/// Call [`prepare_commit`](Self::prepare_commit) to finalize and collect file metadata.
pub(crate) struct DataFileWriter {
file_io: FileIO,
table_location: String,
partition_path: String,
bucket: i32,
schema_id: i64,
target_file_size: i64,
file_compression: String,
file_compression_zstd_level: i32,
write_buffer_size: i64,
file_format: String,
write_fields: Vec<DataField>,
file_source: Option<i32>,
first_row_id: Option<i64>,
write_cols: Option<Vec<String>>,
written_files: Vec<DataFileMeta>,
/// Background file close tasks spawned during rolling.
in_flight_closes: JoinSet<Result<DataFileMeta>>,
/// Current open format writer, lazily created on first write.
current_writer: Option<Box<dyn FormatFileWriter>>,
current_file_name: Option<String>,
current_row_count: i64,
}
impl DataFileWriter {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
file_io: FileIO,
table_location: String,
partition_path: String,
bucket: i32,
schema_id: i64,
target_file_size: i64,
file_compression: String,
file_compression_zstd_level: i32,
write_buffer_size: i64,
file_format: String,
write_fields: Vec<DataField>,
file_source: Option<i32>,
first_row_id: Option<i64>,
write_cols: Option<Vec<String>>,
) -> Self {
Self {
file_io,
table_location,
partition_path,
bucket,
schema_id,
target_file_size,
file_compression,
file_compression_zstd_level,
write_buffer_size,
file_format,
write_fields,
file_source,
first_row_id,
write_cols,
written_files: Vec::new(),
in_flight_closes: JoinSet::new(),
current_writer: None,
current_file_name: None,
current_row_count: 0,
}
}
/// Write a RecordBatch. Rolls to a new file when target size is reached.
pub(crate) async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
if batch.num_rows() == 0 {
return Ok(());
}
if self.current_writer.is_none() {
self.open_new_file(batch.schema()).await?;
}
self.current_row_count += batch.num_rows() as i64;
self.current_writer.as_mut().unwrap().write(batch).await?;
// Roll to a new file if target size is reached — close in background
if self.current_writer.as_ref().unwrap().num_bytes() as i64 >= self.target_file_size {
self.roll_file();
}
// Flush row group if in-progress buffer exceeds write_buffer_size
if let Some(w) = self.current_writer.as_mut() {
if w.in_progress_size() as i64 >= self.write_buffer_size {
w.flush().await?;
}
}
Ok(())
}
async fn open_new_file(&mut self, schema: arrow_schema::SchemaRef) -> Result<()> {
let file_name = format!(
"data-{}-{}.{}",
uuid::Uuid::new_v4(),
self.written_files.len(),
self.file_format,
);
let bucket_dir = if self.partition_path.is_empty() {
format!("{}/{}", self.table_location, bucket_dir_name(self.bucket))
} else {
format!(
"{}/{}/{}",
self.table_location,
self.partition_path,
bucket_dir_name(self.bucket)
)
};
self.file_io.mkdirs(&format!("{bucket_dir}/")).await?;
let file_path = format!("{bucket_dir}/{file_name}");
let output = self.file_io.new_output(&file_path)?;
let writer = create_format_writer(
&output,
schema,
&self.file_compression,
self.file_compression_zstd_level,
Some(self.file_io.clone()),
Some(&self.write_fields),
)
.await?;
self.current_writer = Some(writer);
self.current_file_name = Some(file_name);
self.current_row_count = 0;
Ok(())
}
/// Close the current file writer and record the file metadata.
pub(crate) async fn close_current_file(&mut self) -> Result<()> {
let writer = match self.current_writer.take() {
Some(w) => w,
None => return Ok(()),
};
let file_name = self.current_file_name.take().unwrap();
let row_count = self.current_row_count;
self.current_row_count = 0;
let file_size = writer.close().await? as i64;
let meta = Self::build_meta(
file_name,
file_size,
row_count,
self.schema_id,
self.file_source,
self.first_row_id,
self.write_cols.clone(),
);
self.written_files.push(meta);
Ok(())
}
/// Spawn the current writer's close in the background for non-blocking rolling.
fn roll_file(&mut self) {
let writer = match self.current_writer.take() {
Some(w) => w,
None => return,
};
let file_name = self.current_file_name.take().unwrap();
let row_count = self.current_row_count;
self.current_row_count = 0;
let schema_id = self.schema_id;
let file_source = self.file_source;
let first_row_id = self.first_row_id;
let write_cols = self.write_cols.clone();
self.in_flight_closes.spawn(async move {
let file_size = writer.close().await? as i64;
Ok(Self::build_meta(
file_name,
file_size,
row_count,
schema_id,
file_source,
first_row_id,
write_cols,
))
});
}
/// Close the current writer and return all written file metadata.
pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>> {
self.close_current_file().await?;
while let Some(result) = self.in_flight_closes.join_next().await {
let meta = result.map_err(|e| crate::Error::DataInvalid {
message: format!("Background file close task panicked: {e}"),
source: None,
})??;
self.written_files.push(meta);
}
Ok(std::mem::take(&mut self.written_files))
}
fn build_meta(
file_name: String,
file_size: i64,
row_count: i64,
schema_id: i64,
file_source: Option<i32>,
first_row_id: Option<i64>,
write_cols: Option<Vec<String>>,
) -> DataFileMeta {
DataFileMeta {
file_name,
file_size,
row_count,
min_key: EMPTY_SERIALIZED_ROW.clone(),
max_key: EMPTY_SERIALIZED_ROW.clone(),
key_stats: BinaryTableStats::new(
EMPTY_SERIALIZED_ROW.clone(),
EMPTY_SERIALIZED_ROW.clone(),
vec![],
),
value_stats: BinaryTableStats::new(
EMPTY_SERIALIZED_ROW.clone(),
EMPTY_SERIALIZED_ROW.clone(),
vec![],
),
min_sequence_number: 0,
max_sequence_number: 0,
schema_id,
level: 0,
extra_files: vec![],
creation_time: Some(Utc::now()),
delete_row_count: Some(0),
embedded_index: None,
file_source,
value_stats_cols: Some(vec![]),
external_path: None,
first_row_id,
write_cols,
}
}
}