use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use arrow::record_batch::RecordBatch;
use datafusion::error::DataFusionError;
use datafusion::execution::SendableRecordBatchStream;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use futures::StreamExt;
use object_store::ObjectStore;
use object_store::buffered::BufWriter as ObjectBufWriter;
use object_store::path::Path as ObjectPath;
use parquet::arrow::ArrowWriter;
use parquet::basic::Compression;
use parquet::file::properties::WriterProperties;
use tempfile::NamedTempFile;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use crate::Result;
use crate::metadata_writer::{
ColumnDef, DataFileInfo, DeleteFileEntry, DeleteFileInfo, MetadataWriter,
SnapshotCommitMetadata, WriteMode, WriteResult, validate_delete_entries,
};
use crate::path_resolver::join_paths;
use crate::row_id::{embedded_rowid_field, embedded_snapshot_id_field};
use crate::table::delete_file_schema;
pub use crate::partition::PartitionGroup;
pub const DEFAULT_MAX_OPEN_PARTITIONS: usize = 100;
pub const MINIMUM_TARGET_FILE_SIZE: usize = 4096;
pub const DEFAULT_TARGET_FILE_SIZE: usize = 1 << 29;
#[derive(Debug, Clone, Default)]
pub struct DuckLakeWriteOptions {
pub compression: Option<Compression>,
pub max_row_group_rows: Option<usize>,
pub max_row_group_bytes: Option<usize>,
pub target_file_size: Option<usize>,
pub max_open_partitions: Option<usize>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TableWriteOptions {
pub commit_metadata: SnapshotCommitMetadata,
pub expected_base_snapshot_id: Option<i64>,
}
impl TableWriteOptions {
#[must_use]
pub const fn new() -> Self {
Self {
commit_metadata: SnapshotCommitMetadata::new(),
expected_base_snapshot_id: None,
}
}
#[must_use]
pub fn with_commit_metadata(mut self, commit_metadata: SnapshotCommitMetadata) -> Self {
self.commit_metadata = commit_metadata;
self
}
#[must_use]
pub const fn with_expected_base_snapshot_id(mut self, snapshot_id: i64) -> Self {
self.expected_base_snapshot_id = Some(snapshot_id);
self
}
}
#[derive(Debug)]
pub struct DuckLakeTableWriter {
metadata: Arc<dyn MetadataWriter>,
object_store: Arc<dyn ObjectStore>,
base_key_path: String,
compression: Compression,
max_row_group_rows: Option<usize>,
max_row_group_bytes: Option<usize>,
target_file_size: usize,
max_open_partitions: usize,
}
impl DuckLakeTableWriter {
pub fn new(
metadata: Arc<dyn MetadataWriter>,
object_store: Arc<dyn ObjectStore>,
) -> Result<Self> {
let data_path_str = metadata.get_data_path()?;
let (_, key_path) = crate::path_resolver::parse_object_store_url(&data_path_str)?;
Ok(Self {
metadata,
object_store,
base_key_path: key_path,
compression: Compression::UNCOMPRESSED,
max_row_group_rows: None,
max_row_group_bytes: None,
target_file_size: DEFAULT_TARGET_FILE_SIZE,
max_open_partitions: DEFAULT_MAX_OPEN_PARTITIONS,
})
}
pub fn with_compression(mut self, compression: Compression) -> Self {
self.compression = compression;
self
}
pub fn with_max_row_group_rows(mut self, rows: usize) -> Self {
self.max_row_group_rows = Some(rows);
self
}
pub fn with_max_row_group_bytes(mut self, bytes: usize) -> Self {
self.max_row_group_bytes = Some(bytes);
self
}
pub fn with_target_file_size(mut self, bytes: usize) -> Self {
self.target_file_size = bytes.max(MINIMUM_TARGET_FILE_SIZE);
self
}
pub fn target_file_size(&self) -> usize {
self.target_file_size
}
pub fn with_max_open_partitions(mut self, files: usize) -> Self {
self.max_open_partitions = files.max(1);
self
}
pub fn with_options(mut self, options: &DuckLakeWriteOptions) -> Self {
if let Some(compression) = options.compression {
self.compression = compression;
}
if let Some(rows) = options.max_row_group_rows {
self.max_row_group_rows = Some(rows);
}
if let Some(bytes) = options.max_row_group_bytes {
self.max_row_group_bytes = Some(bytes);
}
if let Some(bytes) = options.target_file_size {
self.target_file_size = bytes;
}
if let Some(files) = options.max_open_partitions {
self.max_open_partitions = files.max(1);
}
self
}
fn build_writer_props(&self) -> WriterProperties {
let mut builder = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.set_compression(self.compression);
if let Some(rows) = self.max_row_group_rows {
builder = builder.set_max_row_group_row_count(Some(rows));
}
if let Some(bytes) = self.max_row_group_bytes {
builder = builder.set_max_row_group_bytes(Some(bytes));
}
builder.build()
}
pub fn begin_write(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
) -> Result<TableWriteSession> {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let file_name = format!("{}.parquet", Uuid::new_v4());
self.begin_write_internal(
schema_name,
table_name,
arrow_schema,
table_key,
file_name.clone(),
file_name,
true,
false,
mode,
StreamPartitionMode::Split,
true,
)
}
pub fn begin_write_single_file(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
) -> Result<TableWriteSession> {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let file_name = format!("{}.parquet", Uuid::new_v4());
self.begin_write_internal(
schema_name,
table_name,
arrow_schema,
table_key,
file_name.clone(),
file_name,
true,
false,
mode,
StreamPartitionMode::Split,
false,
)
}
pub fn begin_write_with_embedded_rowid(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
) -> Result<TableWriteSession> {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let file_name = format!("{}.parquet", Uuid::new_v4());
self.begin_write_internal(
schema_name,
table_name,
arrow_schema,
table_key,
file_name.clone(),
file_name,
true,
true,
mode,
StreamPartitionMode::Split,
false,
)
}
pub fn begin_write_to_path(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
file_dir: &str,
file_name: String,
mode: WriteMode,
) -> Result<TableWriteSession> {
let full_path = join_paths(file_dir, &file_name)?;
self.begin_write_internal(
schema_name,
table_name,
arrow_schema,
file_dir.to_string(),
file_name,
full_path,
false,
false,
mode,
StreamPartitionMode::Reject {
entry_point: "begin_write_to_path",
},
false,
)
}
#[allow(clippy::too_many_arguments)]
fn begin_write_internal(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
file_dir: String,
file_name: String,
catalog_path: String,
path_is_relative: bool,
embed_rowid: bool,
mode: WriteMode,
partition_mode: StreamPartitionMode,
roll: bool,
) -> Result<TableWriteSession> {
let columns = arrow_schema_to_column_defs(arrow_schema)?;
let setup =
self.metadata
.begin_write_transaction(schema_name, table_name, &columns, mode)?;
let schema_with_ids = {
let mut schema = build_schema_with_field_ids(arrow_schema, &setup.field_ids)?;
if embed_rowid {
let mut fields: Vec<Field> =
schema.fields().iter().map(|f| f.as_ref().clone()).collect();
fields.push(embedded_rowid_field());
schema = Schema::new_with_metadata(fields, schema.metadata().clone());
}
Arc::new(schema)
};
let object_path_str = join_paths(&file_dir, &file_name)?;
let object_path = ObjectPath::from(object_path_str.trim_start_matches('/'));
let mut props_builder = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.set_compression(self.compression);
if let Some(rows) = self.max_row_group_rows {
props_builder = props_builder.set_max_row_group_row_count(Some(rows));
}
if let Some(bytes) = self.max_row_group_bytes {
props_builder = props_builder.set_max_row_group_bytes(Some(bytes));
}
let props = props_builder.build();
let temp = NamedTempFile::new()?;
let staging = std::io::BufWriter::new(temp.reopen()?);
let writer = ArrowWriter::try_new(staging, schema_with_ids.clone(), Some(props))?;
let partition_sink =
match self.resolve_partition(setup.table_id, &setup.column_ids, arrow_schema)? {
None => None,
Some(spec) => match partition_mode {
StreamPartitionMode::Reject {
entry_point,
} => {
return Err(crate::error::DuckLakeError::Unsupported(format!(
"{entry_point} does not support a partitioned table: it writes to one \
caller-determined file, but the table's partition spec requires rows \
to be split across one file per partition"
)));
},
StreamPartitionMode::Split => {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key =
join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
Some(PartitionSink {
key_names: spec.key_names(),
spec,
table_key,
schema_with_ids: schema_with_ids.clone(),
column_ids: setup.column_ids.clone(),
props: self.build_writer_props(),
target_file_size: self.target_file_size,
max_open: self.max_open_partitions,
open: Vec::new(),
staged: Vec::new(),
})
},
},
};
let roller = if roll && partition_sink.is_none() {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
Some(RollingFileWriter::new(
table_key,
None,
schema_with_ids.clone(),
arrow_schema.fields().len(),
self.build_writer_props(),
self.target_file_size,
Some(catalog_path.clone()),
))
} else {
None
};
Ok(TableWriteSession {
metadata: Arc::clone(&self.metadata),
object_store: Arc::clone(&self.object_store),
object_path,
schema_name: schema_name.to_string(),
table_name: table_name.to_string(),
snapshot_id: setup.snapshot_id,
base_snapshot_id: setup.base_snapshot_id,
expected_base_snapshot_id: None,
table_id: setup.table_id,
columns,
column_ids: setup.column_ids,
field_ids: setup.field_ids,
schema_with_ids,
writer: Some(writer),
temp: Some(temp),
catalog_path,
path_is_relative,
mode,
row_count: 0,
nan_flags: Vec::new(),
partition_sink,
roller,
rolled: Vec::new(),
commit_metadata: SnapshotCommitMetadata::default(),
})
}
pub async fn write_table(
&self,
schema_name: &str,
table_name: &str,
batches: &[RecordBatch],
) -> Result<WriteResult> {
self.write_all(schema_name, table_name, batches, WriteMode::Replace)
.await
}
pub async fn append_table(
&self,
schema_name: &str,
table_name: &str,
batches: &[RecordBatch],
) -> Result<WriteResult> {
self.write_all(schema_name, table_name, batches, WriteMode::Append)
.await
}
async fn write_all(
&self,
schema_name: &str,
table_name: &str,
batches: &[RecordBatch],
mode: WriteMode,
) -> Result<WriteResult> {
if batches.is_empty() {
return Err(crate::error::DuckLakeError::InvalidConfig(
"No batches to write".to_string(),
));
}
let arrow_schema = batches[0].schema();
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
if total_rows > 0 {
return self
.write_rows(schema_name, table_name, &arrow_schema, mode, batches)
.await;
}
let mut session = self.begin_write(schema_name, table_name, &arrow_schema, mode)?;
for batch in batches {
session.write_batch(batch)?;
}
session.finish().await
}
pub async fn write_delete_file(
&self,
schema_name: &str,
table_name: &str,
data_file_path: &str,
positions: &[i64],
) -> Result<DeleteFileInfo> {
use arrow::array::{Int64Array, StringArray};
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let file_name = format!("{}.parquet", Uuid::new_v4());
let object_path_str = join_paths(&table_key, &file_name)?;
let object_path = ObjectPath::from(object_path_str.trim_start_matches('/'));
let schema = delete_file_schema();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(vec![data_file_path; positions.len()])),
Arc::new(Int64Array::from(positions.to_vec())),
],
)?;
let props = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.set_compression(self.compression)
.build();
let temp = NamedTempFile::new()?;
let staging = std::io::BufWriter::new(temp.reopen()?);
let mut writer = ArrowWriter::try_new(staging, schema, Some(props))?;
writer.write(&batch)?;
let staged = writer.into_inner()?;
let mut file = staged
.into_inner()
.map_err(|e| crate::error::DuckLakeError::Io(e.into_error()))?;
let file_size = file.metadata()?.len() as i64;
let footer_size = read_footer_size(&mut file)?;
let local = tokio::fs::File::open(temp.path()).await?;
let mut reader = tokio::io::BufReader::new(local);
let mut upload = ObjectBufWriter::new(Arc::clone(&self.object_store), object_path);
if let Err(e) = stream_to_upload(&mut reader, &mut upload).await {
let _ = upload.abort().await;
return Err(e.into());
}
Ok(
DeleteFileInfo::new(file_name, file_size, positions.len() as i64)
.with_footer_size(footer_size),
)
}
#[allow(clippy::too_many_arguments)]
pub async fn write_compacted_file(
&self,
schema_name: &str,
table_name: &str,
data_schema: &Schema,
data_column_ids: &[i64],
stats_column_ids: &[i64],
batches: &[RecordBatch],
embed_snapshot_id: bool,
partition_subpath: Option<&str>,
) -> Result<DataFileInfo> {
let stream_schema = batches.first().map_or_else(
|| {
let mut fields: Vec<Field> = data_schema
.fields()
.iter()
.map(|field| field.as_ref().clone())
.collect();
fields.push(embedded_rowid_field());
if embed_snapshot_id {
fields.push(embedded_snapshot_id_field());
}
Arc::new(Schema::new(fields))
},
RecordBatch::schema,
);
let batches = batches.to_vec();
let stream =
futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
let stream = Box::pin(RecordBatchStreamAdapter::new(stream_schema, stream));
self.write_compacted_file_stream(
schema_name,
table_name,
data_schema,
data_column_ids,
stats_column_ids,
stream,
embed_snapshot_id,
partition_subpath,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn write_compacted_file_stream(
&self,
schema_name: &str,
table_name: &str,
data_schema: &Schema,
data_column_ids: &[i64],
stats_column_ids: &[i64],
mut batches: SendableRecordBatchStream,
embed_snapshot_id: bool,
partition_subpath: Option<&str>,
) -> Result<DataFileInfo> {
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let file_name = match partition_subpath {
Some(prefix) if !prefix.is_empty() => {
format!("{prefix}/{}.parquet", Uuid::new_v4())
},
_ => format!("{}.parquet", Uuid::new_v4()),
};
let object_path_str = join_paths(&table_key, &file_name)?;
let object_path = ObjectPath::from(object_path_str.trim_start_matches('/'));
let schema_with_ids = {
let base = build_schema_with_field_ids(data_schema, data_column_ids)?;
let mut fields: Vec<Field> = base.fields().iter().map(|f| f.as_ref().clone()).collect();
fields.push(embedded_rowid_field());
if embed_snapshot_id {
fields.push(embedded_snapshot_id_field());
}
Arc::new(Schema::new_with_metadata(fields, base.metadata().clone()))
};
let mut props_builder = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.set_compression(self.compression);
if let Some(rows) = self.max_row_group_rows {
props_builder = props_builder.set_max_row_group_row_count(Some(rows));
}
if let Some(bytes) = self.max_row_group_bytes {
props_builder = props_builder.set_max_row_group_bytes(Some(bytes));
}
let props = props_builder.build();
let temp = NamedTempFile::new()?;
let staging = std::io::BufWriter::new(temp.reopen()?);
let mut writer = ArrowWriter::try_new(staging, schema_with_ids.clone(), Some(props))?;
let mut row_count: i64 = 0;
let mut nan_flags: Vec<Option<bool>> = Vec::new();
while let Some(batch) = batches.next().await {
let batch = batch?;
if batch.num_columns() != schema_with_ids.fields().len() {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"write_compacted_file: batch has {} columns, expected {}",
batch.num_columns(),
schema_with_ids.fields().len()
)));
}
let batch_with_ids = apply_field_ids(&batch, schema_with_ids.clone())?;
crate::stats_collect::accumulate_nan_flags(
&mut nan_flags,
&batch,
stats_column_ids.len(),
);
writer.write(&batch_with_ids)?;
row_count += batch.num_rows() as i64;
}
let staged = writer.into_inner()?;
let mut file = staged
.into_inner()
.map_err(|e| crate::error::DuckLakeError::Io(e.into_error()))?;
let file_size = file.metadata()?.len() as i64;
let footer_size = read_footer_size(&mut file)?;
let local = tokio::fs::File::open(temp.path()).await?;
let mut reader = tokio::io::BufReader::new(local);
let mut upload = ObjectBufWriter::new(Arc::clone(&self.object_store), object_path);
if let Err(e) = stream_to_upload(&mut reader, &mut upload).await {
let _ = upload.abort().await;
return Err(e.into());
}
let column_stats = crate::stats_collect::collect_column_stats(
temp.path(),
stats_column_ids,
row_count,
&nan_flags,
);
Ok(DataFileInfo::new(file_name, file_size, row_count)
.with_footer_size(footer_size)
.with_column_stats(column_stats))
}
#[allow(clippy::too_many_arguments)]
pub async fn write_partitioned(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
partition_id: i64,
key_names: &[String],
groups: Vec<PartitionGroup>,
) -> Result<WriteResult> {
self.write_partitioned_with_commit_metadata(
schema_name,
table_name,
arrow_schema,
mode,
partition_id,
key_names,
groups,
&SnapshotCommitMetadata::default(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn write_partitioned_with_commit_metadata(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
partition_id: i64,
key_names: &[String],
groups: Vec<PartitionGroup>,
commit_metadata: &SnapshotCommitMetadata,
) -> Result<WriteResult> {
let options = TableWriteOptions::new().with_commit_metadata(commit_metadata.clone());
self.write_partitioned_with_options(
schema_name,
table_name,
arrow_schema,
mode,
partition_id,
key_names,
groups,
&options,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn write_partitioned_with_options(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
partition_id: i64,
key_names: &[String],
groups: Vec<PartitionGroup>,
options: &TableWriteOptions,
) -> Result<WriteResult> {
if groups.is_empty() {
return Err(crate::error::DuckLakeError::InvalidConfig(
"write_partitioned: no partition groups".to_string(),
));
}
let columns = arrow_schema_to_column_defs(arrow_schema)?;
let setup =
self.metadata
.begin_write_transaction(schema_name, table_name, &columns, mode)?;
let schema_with_ids =
Arc::new(build_schema_with_field_ids(arrow_schema, &setup.field_ids)?);
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
if let Some(spec) =
self.resolve_partition(setup.table_id, &setup.column_ids, arrow_schema)?
{
if spec.partition_id != partition_id {
return Err(crate::error::DuckLakeError::Conflict(format!(
"write_partitioned targets partition spec {partition_id} but the table's live \
generation is {}; re-resolve the spec and retry",
spec.partition_id
)));
}
for (values, _) in &groups {
spec.validate_values(arrow_schema, values)?;
}
}
let file_infos = self
.write_partition_groups(
&table_key,
schema_with_ids,
&setup.column_ids,
partition_id,
key_names,
&groups,
)
.await?;
let records_written: i64 = file_infos.iter().map(|f| f.record_count).sum();
if file_infos.is_empty() {
return Err(crate::error::DuckLakeError::InvalidConfig(
"write_partitioned: partition groups produced no rows".to_string(),
));
}
let committed = self.metadata.register_data_files_with_commit_metadata(
setup.table_id,
schema_name,
table_name,
setup.snapshot_id,
&file_infos,
mode,
options
.expected_base_snapshot_id
.unwrap_or(setup.base_snapshot_id),
&columns,
&setup.field_ids,
&options.commit_metadata,
options.expected_base_snapshot_id,
)?;
Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: file_infos.len(),
records_written,
})
}
pub async fn write_rows(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
) -> Result<WriteResult> {
self.write_rows_inner(schema_name, table_name, arrow_schema, mode, batches, true)
.await
}
pub(crate) async fn write_rows_unpartitioned_as_planned(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
) -> Result<WriteResult> {
self.write_rows_inner(schema_name, table_name, arrow_schema, mode, batches, false)
.await
}
async fn write_rows_inner(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
resolve_layout: bool,
) -> Result<WriteResult> {
let columns = arrow_schema_to_column_defs(arrow_schema)?;
let setup =
self.metadata
.begin_write_transaction(schema_name, table_name, &columns, mode)?;
let schema_with_ids =
Arc::new(build_schema_with_field_ids(arrow_schema, &setup.field_ids)?);
let scoped_base = match self.metadata.catalog_id() {
Some(id) => join_paths(&self.base_key_path, &format!("cat_{id}"))?,
None => self.base_key_path.clone(),
};
let table_key = join_paths(&join_paths(&scoped_base, schema_name)?, table_name)?;
let partition = if resolve_layout {
self.resolve_partition(setup.table_id, &setup.column_ids, arrow_schema)?
} else {
None
};
let sorted_owned: Vec<RecordBatch> = if resolve_layout {
let lengths: Vec<usize> = batches.iter().map(|b| b.num_rows()).collect();
let sorted = crate::sort::sort_batches_by_spec(
batches.to_vec(),
arrow_schema,
self.metadata.live_sort_spec(setup.table_id)?.as_ref(),
)?;
reslice_to_lengths(sorted, &lengths)
} else {
Vec::new()
};
let batches: &[RecordBatch] = if resolve_layout {
&sorted_owned
} else {
batches
};
let file_infos = match partition.as_ref() {
Some(spec) => {
let output_schema: SchemaRef = Arc::new(arrow_schema.clone());
let groups =
crate::partition::split_batches_by_partition(&output_schema, batches, spec)?;
self.write_partition_groups(
&table_key,
schema_with_ids,
&setup.column_ids,
spec.partition_id,
&spec.key_names(),
&groups,
)
.await?
},
None => {
self.write_rolled_files(
&table_key,
None,
schema_with_ids,
&setup.column_ids,
batches,
)
.await?
},
};
if file_infos.is_empty() {
return Err(crate::error::DuckLakeError::InvalidConfig(
"write_rows: input produced no rows".to_string(),
));
}
let records_written: i64 = file_infos.iter().map(|f| f.record_count).sum();
let committed = self.metadata.register_data_files(
setup.table_id,
schema_name,
table_name,
setup.snapshot_id,
&file_infos,
mode,
setup.base_snapshot_id,
&columns,
&setup.field_ids,
)?;
Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: file_infos.len(),
records_written,
})
}
fn resolve_partition(
&self,
table_id: i64,
column_ids: &[i64],
arrow_schema: &Schema,
) -> Result<Option<crate::partition::PartitionWriteSpec>> {
match self.metadata.live_partition_spec(table_id)? {
None => Ok(None),
Some(spec) => Ok(Some(crate::partition::PartitionWriteSpec::resolve(
&spec,
column_ids,
arrow_schema,
)?)),
}
}
async fn write_partition_groups(
&self,
table_key: &str,
schema_with_ids: SchemaRef,
column_ids: &[i64],
partition_id: i64,
key_names: &[String],
groups: &[PartitionGroup],
) -> Result<Vec<DataFileInfo>> {
let mut file_infos: Vec<DataFileInfo> = Vec::with_capacity(groups.len());
for (values, batches) in groups {
let rel = crate::partition::hive_subpath(key_names, values);
let rel_prefix = if rel.is_empty() {
None
} else {
Some(rel.as_str())
};
let group_files = self
.write_rolled_files(
table_key,
rel_prefix,
schema_with_ids.clone(),
column_ids,
batches,
)
.await?;
let partition_values: Vec<(i32, Option<String>)> = values
.iter()
.enumerate()
.map(|(i, v)| (i as i32, v.clone()))
.collect();
for info in group_files {
file_infos.push(info.with_partition(partition_id, partition_values.clone()));
}
}
Ok(file_infos)
}
async fn write_rolled_files(
&self,
table_key: &str,
rel_prefix: Option<&str>,
schema_with_ids: SchemaRef,
column_ids: &[i64],
batches: &[RecordBatch],
) -> Result<Vec<DataFileInfo>> {
let data_column_count = schema_with_ids.fields().len();
let mut roller = RollingFileWriter::new(
table_key.to_string(),
rel_prefix.map(str::to_string),
schema_with_ids,
data_column_count,
self.build_writer_props(),
self.target_file_size,
None,
);
let mut files: Vec<DataFileInfo> = Vec::new();
for batch in batches {
if let Some(staged) = roller.write(batch)? {
files.push(upload_staged_file(staged, &self.object_store, column_ids).await?);
}
}
if let Some(staged) = roller.finish()? {
files.push(upload_staged_file(staged, &self.object_store, column_ids).await?);
}
Ok(files)
}
}
fn reslice_to_lengths(batches: Vec<RecordBatch>, lengths: &[usize]) -> Vec<RecordBatch> {
let total: usize = lengths.iter().sum();
if batches.len() != 1 || batches[0].num_rows() != total || lengths.len() <= 1 {
return batches;
}
let combined = &batches[0];
let mut out = Vec::with_capacity(lengths.len());
let mut offset = 0usize;
for len in lengths {
if *len == 0 {
continue;
}
out.push(combined.slice(offset, *len));
offset += *len;
}
out
}
#[derive(Debug)]
struct OpenFile {
writer: ArrowWriter<std::io::BufWriter<std::fs::File>>,
temp: NamedTempFile,
catalog_path: String,
object_path: ObjectPath,
row_count: i64,
nan_flags: Vec<Option<bool>>,
}
#[derive(Debug)]
struct StagedFile {
temp: tempfile::TempPath,
catalog_path: String,
object_path: ObjectPath,
row_count: i64,
nan_flags: Vec<Option<bool>>,
}
#[derive(Debug)]
struct RollingFileWriter {
table_key: String,
rel_prefix: Option<String>,
schema_with_ids: SchemaRef,
data_column_count: usize,
props: WriterProperties,
target_file_size: usize,
open: Option<OpenFile>,
first_catalog_path: Option<String>,
}
impl RollingFileWriter {
fn new(
table_key: String,
rel_prefix: Option<String>,
schema_with_ids: SchemaRef,
data_column_count: usize,
props: WriterProperties,
target_file_size: usize,
first_catalog_path: Option<String>,
) -> Self {
Self {
first_catalog_path,
table_key,
rel_prefix,
schema_with_ids,
data_column_count,
props,
target_file_size,
open: None,
}
}
fn write(&mut self, batch: &RecordBatch) -> Result<Option<StagedFile>> {
if batch.num_rows() == 0 {
return Ok(None);
}
if self.open.is_none() {
self.open = Some(self.open_file()?);
}
let batch_with_ids = apply_field_ids(batch, self.schema_with_ids.clone())?;
let open = self.open.as_mut().expect("file opened above");
crate::stats_collect::accumulate_nan_flags(
&mut open.nan_flags,
&batch_with_ids,
self.data_column_count,
);
open.writer.write(&batch_with_ids)?;
open.row_count += batch.num_rows() as i64;
if open.writer.bytes_written() + open.writer.in_progress_size() > self.target_file_size {
return Ok(Some(finalize_open_file(
self.open.take().expect("file open"),
)?));
}
Ok(None)
}
fn finish(&mut self) -> Result<Option<StagedFile>> {
match self.open.take() {
Some(open) => Ok(Some(finalize_open_file(open)?)),
None => Ok(None),
}
}
fn has_open_file(&self) -> bool {
self.open.is_some()
}
fn open_file(&mut self) -> Result<OpenFile> {
let catalog_path = match self.first_catalog_path.take() {
Some(path) => path,
None => {
let file_name = format!("{}.parquet", Uuid::new_v4());
match self.rel_prefix.as_deref() {
Some(prefix) if !prefix.is_empty() => format!("{prefix}/{file_name}"),
_ => file_name,
}
},
};
let object_path_str = join_paths(&self.table_key, &catalog_path)?;
let object_path = ObjectPath::from(object_path_str.trim_start_matches('/'));
let temp = NamedTempFile::new()?;
let staging = std::io::BufWriter::new(temp.reopen()?);
let writer = ArrowWriter::try_new(
staging,
self.schema_with_ids.clone(),
Some(self.props.clone()),
)?;
Ok(OpenFile {
writer,
temp,
catalog_path,
object_path,
row_count: 0,
nan_flags: Vec::new(),
})
}
}
#[tracing::instrument(name = "ducklake.finalize_open_file", level = "info", skip_all)]
fn finalize_open_file(file: OpenFile) -> Result<StagedFile> {
let staged = file.writer.into_inner()?;
staged
.into_inner()
.map_err(|e| crate::error::DuckLakeError::Io(e.into_error()))?;
Ok(StagedFile {
temp: file.temp.into_temp_path(),
catalog_path: file.catalog_path,
object_path: file.object_path,
row_count: file.row_count,
nan_flags: file.nan_flags,
})
}
#[tracing::instrument(name = "ducklake.upload_staged_file", level = "info", skip_all)]
async fn upload_staged_file(
staged: StagedFile,
object_store: &Arc<dyn ObjectStore>,
column_ids: &[i64],
) -> Result<DataFileInfo> {
let mut file = std::fs::File::open(&staged.temp)?;
let file_size = file.metadata()?.len() as i64;
let footer_size = read_footer_size(&mut file)?;
let local = tokio::fs::File::open(&staged.temp).await?;
let mut reader = tokio::io::BufReader::new(local);
let mut upload = ObjectBufWriter::new(Arc::clone(object_store), staged.object_path.clone());
if let Err(e) = stream_to_upload(&mut reader, &mut upload).await {
let _ = upload.abort().await;
return Err(e.into());
}
let column_stats = crate::stats_collect::collect_column_stats(
&staged.temp,
column_ids,
staged.row_count,
&staged.nan_flags,
);
Ok(
DataFileInfo::new(&staged.catalog_path, file_size, staged.row_count)
.with_footer_size(footer_size)
.with_column_stats(column_stats),
)
}
#[derive(Debug, Clone, Copy)]
enum StreamPartitionMode {
Split,
Reject {
entry_point: &'static str,
},
}
#[derive(Debug)]
struct PartitionSink {
spec: crate::partition::PartitionWriteSpec,
key_names: Vec<String>,
table_key: String,
schema_with_ids: SchemaRef,
column_ids: Vec<i64>,
props: WriterProperties,
target_file_size: usize,
max_open: usize,
open: Vec<(Vec<Option<String>>, RollingFileWriter)>,
staged: Vec<(Vec<Option<String>>, StagedFile)>,
}
impl PartitionSink {
fn write_batch(&mut self, batch: &RecordBatch) -> Result<()> {
let batch_with_ids = apply_field_ids(batch, self.schema_with_ids.clone())?;
let groups = crate::partition::split_batches_by_partition(
&self.schema_with_ids,
std::slice::from_ref(&batch_with_ids),
&self.spec,
)?;
for (values, batches) in groups {
for group_batch in batches {
if group_batch.num_rows() == 0 {
continue;
}
self.write_group_batch(&values, &group_batch)?;
}
}
Ok(())
}
fn write_group_batch(&mut self, values: &[Option<String>], batch: &RecordBatch) -> Result<()> {
let index = match self.open.iter().position(|(v, _)| v == values) {
Some(index) => index,
None => {
if self.open.len() >= self.max_open {
let (evicted_values, mut evicted) = self.open.remove(0);
if let Some(staged) = evicted.finish()? {
self.staged.push((evicted_values, staged));
}
}
let rel = crate::partition::hive_subpath(&self.key_names, values);
self.open.push((
values.to_vec(),
RollingFileWriter::new(
self.table_key.clone(),
if rel.is_empty() {
None
} else {
Some(rel)
},
self.schema_with_ids.clone(),
self.schema_with_ids.fields().len(),
self.props.clone(),
self.target_file_size,
None,
),
));
self.open.len() - 1
},
};
let (partition_values, roller) = &mut self.open[index];
if let Some(staged) = roller.write(batch)? {
let partition_values = partition_values.clone();
self.staged.push((partition_values, staged));
if !self.open[index].1.has_open_file() {
self.open.remove(index);
}
}
Ok(())
}
async fn into_file_infos(
mut self,
object_store: &Arc<dyn ObjectStore>,
) -> Result<Vec<DataFileInfo>> {
for (values, mut roller) in std::mem::take(&mut self.open) {
if let Some(staged) = roller.finish()? {
self.staged.push((values, staged));
}
}
let mut infos = Vec::with_capacity(self.staged.len());
for (values, staged) in std::mem::take(&mut self.staged) {
let partition_values: Vec<(i32, Option<String>)> = values
.iter()
.enumerate()
.map(|(i, v)| (i as i32, v.clone()))
.collect();
let info = upload_staged_file(staged, object_store, &self.column_ids).await?;
infos.push(info.with_partition(self.spec.partition_id, partition_values));
}
Ok(infos)
}
}
#[derive(Debug)]
pub struct TableWriteSession {
metadata: Arc<dyn MetadataWriter>,
object_store: Arc<dyn ObjectStore>,
object_path: ObjectPath,
schema_name: String,
table_name: String,
snapshot_id: i64,
base_snapshot_id: i64,
expected_base_snapshot_id: Option<i64>,
table_id: i64,
columns: Vec<ColumnDef>,
column_ids: Vec<i64>,
field_ids: Vec<i64>,
schema_with_ids: SchemaRef,
writer: Option<ArrowWriter<std::io::BufWriter<std::fs::File>>>,
temp: Option<NamedTempFile>,
catalog_path: String,
path_is_relative: bool,
mode: WriteMode,
row_count: i64,
nan_flags: Vec<Option<bool>>,
partition_sink: Option<PartitionSink>,
roller: Option<RollingFileWriter>,
rolled: Vec<StagedFile>,
commit_metadata: SnapshotCommitMetadata,
}
impl TableWriteSession {
#[must_use]
pub fn with_options(mut self, options: &TableWriteOptions) -> Self {
self.commit_metadata = options.commit_metadata.clone();
if let Some(snapshot_id) = options.expected_base_snapshot_id {
self.base_snapshot_id = snapshot_id;
}
self.expected_base_snapshot_id = options.expected_base_snapshot_id;
self
}
#[must_use]
pub fn with_commit_metadata(mut self, commit_metadata: SnapshotCommitMetadata) -> Self {
self.commit_metadata = commit_metadata;
self
}
pub fn write_batch(&mut self, batch: &RecordBatch) -> Result<()> {
if self.roller.is_some() || self.partition_sink.is_some() {
self.validate_batch_schema(batch)?;
}
if let Some(roller) = &mut self.roller {
let rows = batch.num_rows() as i64;
if let Some(staged) = roller.write(batch)? {
self.rolled.push(staged);
}
self.row_count += rows;
return Ok(());
}
if let Some(sink) = &mut self.partition_sink {
let rows = batch.num_rows() as i64;
sink.write_batch(batch)?;
self.row_count += rows;
return Ok(());
}
if self.writer.is_none() {
return Err(crate::error::DuckLakeError::Internal(
"Writer already closed".to_string(),
));
}
self.validate_batch_schema(batch)?;
let batch_with_ids = apply_field_ids(batch, self.schema_with_ids.clone())?;
crate::stats_collect::accumulate_nan_flags(
&mut self.nan_flags,
&batch_with_ids,
self.schema_with_ids.fields().len(),
);
let writer = self.writer.as_mut().unwrap();
writer.write(&batch_with_ids)?;
self.row_count += batch.num_rows() as i64;
Ok(())
}
fn validate_batch_schema(&self, batch: &RecordBatch) -> Result<()> {
let batch_schema = batch.schema();
let expected_schema = &self.schema_with_ids;
if batch_schema.fields().len() != expected_schema.fields().len() {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Schema mismatch: batch has {} columns, expected {}",
batch_schema.fields().len(),
expected_schema.fields().len()
)));
}
for (i, (batch_field, expected_field)) in batch_schema
.fields()
.iter()
.zip(expected_schema.fields().iter())
.enumerate()
{
if !Self::data_type_contains_ignoring_nested_names(
expected_field.data_type(),
batch_field.data_type(),
) {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Schema mismatch at column {}: batch has type {:?}, expected {:?}",
i,
batch_field.data_type(),
expected_field.data_type()
)));
}
}
Ok(())
}
fn data_type_contains_ignoring_nested_names(expected: &DataType, actual: &DataType) -> bool {
match (expected, actual) {
(DataType::List(expected), DataType::List(actual))
| (DataType::LargeList(expected), DataType::LargeList(actual))
| (DataType::ListView(expected), DataType::ListView(actual))
| (DataType::LargeListView(expected), DataType::LargeListView(actual)) => {
Self::field_contains_ignoring_name(expected, actual)
},
(
DataType::FixedSizeList(expected, expected_size),
DataType::FixedSizeList(actual, actual_size),
) => {
expected_size == actual_size && Self::field_contains_ignoring_name(expected, actual)
},
(DataType::Map(expected, expected_sorted), DataType::Map(actual, actual_sorted)) => {
expected_sorted == actual_sorted
&& Self::field_contains_ignoring_name(expected, actual)
},
(DataType::Struct(expected), DataType::Struct(actual)) => {
expected.len() == actual.len()
&& expected
.iter()
.zip(actual.iter())
.all(|(expected, actual)| {
Self::field_contains_ignoring_name(expected, actual)
})
},
(
DataType::Dictionary(expected_key, expected_value),
DataType::Dictionary(actual_key, actual_value),
) => {
Self::data_type_contains_ignoring_nested_names(expected_key, actual_key)
&& Self::data_type_contains_ignoring_nested_names(expected_value, actual_value)
},
_ => expected.contains(actual),
}
}
fn field_contains_ignoring_name(expected: &Field, actual: &Field) -> bool {
Self::data_type_contains_ignoring_nested_names(expected.data_type(), actual.data_type())
&& expected.dict_is_ordered() == actual.dict_is_ordered()
&& (expected.is_nullable() || !actual.is_nullable())
&& actual.metadata().iter().all(|(key, value)| {
expected
.metadata()
.get(key)
.is_some_and(|expected| expected == value)
})
}
pub fn row_count(&self) -> i64 {
self.row_count
}
pub fn snapshot_id(&self) -> i64 {
self.snapshot_id
}
pub fn file_path(&self) -> &str {
self.object_path.as_ref()
}
#[tracing::instrument(name = "ducklake.write_session_finish", level = "info", skip_all)]
pub async fn finish(mut self) -> Result<WriteResult> {
if let Some(mut roller) = self.roller.take() {
if let Some(staged) = roller.finish()? {
self.rolled.push(staged);
}
let mut file_infos = Vec::with_capacity(self.rolled.len());
for staged in std::mem::take(&mut self.rolled) {
file_infos
.push(upload_staged_file(staged, &self.object_store, &self.column_ids).await?);
}
if file_infos.is_empty() {
return self.finish_single_file().await;
}
let records_written: i64 = file_infos.iter().map(|f| f.record_count).sum();
let committed = self.metadata.register_data_files_with_commit_metadata(
self.table_id,
&self.schema_name,
&self.table_name,
self.snapshot_id,
&file_infos,
self.mode,
self.base_snapshot_id,
&self.columns,
&self.field_ids,
&self.commit_metadata,
self.expected_base_snapshot_id,
)?;
return Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: file_infos.len(),
records_written,
});
}
if let Some(sink) = self.partition_sink.take() {
let file_infos = sink.into_file_infos(&self.object_store).await?;
if file_infos.is_empty() {
return self.finish_single_file().await;
}
let records_written: i64 = file_infos.iter().map(|f| f.record_count).sum();
let committed = self.metadata.register_data_files_with_commit_metadata(
self.table_id,
&self.schema_name,
&self.table_name,
self.snapshot_id,
&file_infos,
self.mode,
self.base_snapshot_id,
&self.columns,
&self.field_ids,
&self.commit_metadata,
self.expected_base_snapshot_id,
)?;
return Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: file_infos.len(),
records_written,
});
}
self.finish_single_file().await
}
async fn finish_single_file(mut self) -> Result<WriteResult> {
let file_info = self.upload_staged().await?;
let committed = self.metadata.register_data_file_with_commit_metadata(
self.table_id,
&self.schema_name,
&self.table_name,
self.snapshot_id,
&file_info,
self.mode,
self.base_snapshot_id,
&self.columns,
&self.field_ids,
&self.commit_metadata,
self.expected_base_snapshot_id,
)?;
Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: 1,
records_written: self.row_count,
})
}
pub async fn finish_with_deletes(mut self, deletes: &[DeleteFileEntry]) -> Result<WriteResult> {
if deletes.is_empty() {
return self.finish().await;
}
validate_delete_entries(self.mode, deletes)?;
let file_infos: Vec<DataFileInfo> = if let Some(sink) = self.partition_sink.take() {
let file_infos = sink.into_file_infos(&self.object_store).await?;
if file_infos.is_empty() {
vec![self.upload_staged().await?]
} else {
file_infos
}
} else if let Some(mut roller) = self.roller.take() {
if let Some(staged) = roller.finish()? {
self.rolled.push(staged);
}
if self.rolled.is_empty() {
vec![self.upload_staged().await?]
} else {
let mut file_infos = Vec::with_capacity(self.rolled.len());
for staged in std::mem::take(&mut self.rolled) {
file_infos.push(
upload_staged_file(staged, &self.object_store, &self.column_ids).await?,
);
}
file_infos
}
} else {
vec![self.upload_staged().await?]
};
let records_written: i64 = file_infos.iter().map(|f| f.record_count).sum();
let committed = match file_infos.as_slice() {
[file_info] => self
.metadata
.register_data_file_with_deletes_and_commit_metadata(
self.table_id,
&self.schema_name,
&self.table_name,
self.snapshot_id,
file_info,
deletes,
self.mode,
self.base_snapshot_id,
&self.columns,
&self.field_ids,
&self.commit_metadata,
self.expected_base_snapshot_id,
)?,
file_infos => self
.metadata
.register_data_files_with_deletes_and_commit_metadata(
self.table_id,
&self.schema_name,
&self.table_name,
self.snapshot_id,
file_infos,
deletes,
self.mode,
self.base_snapshot_id,
&self.columns,
&self.field_ids,
&self.commit_metadata,
self.expected_base_snapshot_id,
)?,
};
Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: file_infos.len(),
records_written,
})
}
#[tracing::instrument(name = "ducklake.upload_staged", level = "info", skip_all)]
async fn upload_staged(&mut self) -> Result<DataFileInfo> {
let writer = self.writer.take().ok_or_else(|| {
crate::error::DuckLakeError::Internal("Writer already closed".to_string())
})?;
let temp = self.temp.take().ok_or_else(|| {
crate::error::DuckLakeError::Internal("Writer already closed".to_string())
})?;
let staged = writer.into_inner()?;
let mut file = staged
.into_inner()
.map_err(|e| crate::error::DuckLakeError::Io(e.into_error()))?;
let file_size = file.metadata()?.len() as i64;
let footer_size = read_footer_size(&mut file)?;
let local = tokio::fs::File::open(temp.path()).await?;
let mut reader = tokio::io::BufReader::new(local);
let mut upload =
ObjectBufWriter::new(Arc::clone(&self.object_store), self.object_path.clone());
if let Err(e) = stream_to_upload(&mut reader, &mut upload).await {
let _ = upload.abort().await;
return Err(e.into());
}
let column_stats = crate::stats_collect::collect_column_stats(
temp.path(),
&self.column_ids,
self.row_count,
&self.nan_flags,
);
let mut file_info = DataFileInfo::new(&self.catalog_path, file_size, self.row_count)
.with_footer_size(footer_size)
.with_column_stats(column_stats);
if !self.path_is_relative {
file_info = file_info.with_absolute_path();
}
Ok(file_info)
}
}
async fn stream_to_upload<R>(reader: &mut R, upload: &mut ObjectBufWriter) -> std::io::Result<()>
where
R: tokio::io::AsyncRead + Unpin + ?Sized,
{
tokio::io::copy(reader, upload).await?;
upload.shutdown().await?;
Ok(())
}
fn read_footer_size(file: &mut std::fs::File) -> Result<i64> {
let len = file.metadata()?.len();
if len < 8 {
return Err(crate::error::DuckLakeError::Internal(
"Invalid Parquet file: too small".to_string(),
));
}
file.seek(SeekFrom::End(-8))?;
let mut tail = [0u8; 8];
file.read_exact(&mut tail)?;
calculate_footer_size_from_bytes(&tail)
}
fn arrow_schema_to_column_defs(schema: &Schema) -> Result<Vec<ColumnDef>> {
schema
.fields()
.iter()
.map(|field| ColumnDef::from_arrow(field.name(), field.data_type(), field.is_nullable()))
.collect()
}
fn build_schema_with_field_ids(schema: &Schema, column_ids: &[i64]) -> Result<Schema> {
fn with_field_id(field: &Field, column_ids: &[i64], next_id: &mut usize) -> Result<Field> {
let field_id = column_ids.get(*next_id).copied().ok_or_else(|| {
crate::error::DuckLakeError::Internal(format!(
"Missing field id for Arrow field '{}' at recursive position {}",
field.name(),
*next_id,
))
})?;
*next_id += 1;
let data_type = match field.data_type() {
DataType::List(child) => DataType::List(Arc::new(
with_field_id(child, column_ids, next_id)?.with_name("element"),
)),
DataType::LargeList(child) => DataType::LargeList(Arc::new(
with_field_id(child, column_ids, next_id)?.with_name("element"),
)),
DataType::FixedSizeList(child, size) => DataType::FixedSizeList(
Arc::new(with_field_id(child, column_ids, next_id)?.with_name("element")),
*size,
),
DataType::Struct(children) => DataType::Struct(
children
.iter()
.map(|child| with_field_id(child, column_ids, next_id).map(Arc::new))
.collect::<Result<Vec<_>>>()?
.into(),
),
DataType::Map(entries, sorted) => {
let DataType::Struct(children) = entries.data_type() else {
return Err(crate::error::DuckLakeError::InvalidConfig(
"Arrow map entries must be a struct".to_string(),
));
};
let entries_type = DataType::Struct(
children
.iter()
.map(|child| with_field_id(child, column_ids, next_id).map(Arc::new))
.collect::<Result<Vec<_>>>()?
.into(),
);
DataType::Map(
Arc::new(
Field::new("key_value", entries_type, entries.is_nullable())
.with_metadata(entries.metadata().clone()),
),
*sorted,
)
},
data_type => data_type.clone(),
};
let mut metadata: HashMap<String, String> = field.metadata().clone();
metadata.insert("PARQUET:field_id".to_string(), field_id.to_string());
Ok(Field::new(field.name(), data_type, field.is_nullable()).with_metadata(metadata))
}
let mut next_id = 0;
let fields = schema
.fields()
.iter()
.map(|field| with_field_id(field, column_ids, &mut next_id))
.collect::<Result<Vec<_>>>()?;
if next_id != column_ids.len() {
return Err(crate::error::DuckLakeError::Internal(format!(
"Field id count {} exceeds Arrow schema node count {next_id}",
column_ids.len(),
)));
}
Ok(Schema::new_with_metadata(fields, schema.metadata().clone()))
}
fn apply_field_ids(batch: &RecordBatch, schema: SchemaRef) -> Result<RecordBatch> {
let columns = batch
.columns()
.iter()
.zip(schema.fields())
.map(|(column, field)| {
crate::column_rename::array_with_data_type(column, field.data_type())
.map_err(crate::error::DuckLakeError::Arrow)
})
.collect::<Result<Vec<_>>>()?;
Ok(crate::column_rename::record_batch_with_schema(
schema, columns,
)?)
}
fn calculate_footer_size_from_bytes(buffer: &[u8]) -> Result<i64> {
if buffer.len() < 8 {
return Err(crate::error::DuckLakeError::Internal(
"Invalid Parquet file: too small".to_string(),
));
}
let footer_bytes = &buffer[buffer.len() - 8..];
if &footer_bytes[4..8] != b"PAR1" {
return Err(crate::error::DuckLakeError::Internal(
"Invalid Parquet file: missing PAR1 magic".to_string(),
));
}
let metadata_len =
i32::from_le_bytes([footer_bytes[0], footer_bytes[1], footer_bytes[2], footer_bytes[3]])
as i64;
Ok(metadata_len + 8)
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{Decimal128Array, Int32Array, StringArray, StringViewArray, StructArray};
use arrow::datatypes::DataType;
#[test]
fn test_arrow_schema_to_column_defs() {
let schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]);
let columns = arrow_schema_to_column_defs(&schema).unwrap();
assert_eq!(columns.len(), 2);
assert_eq!(columns[0].name, "id");
assert_eq!(columns[0].ducklake_type, "int32");
assert!(!columns[0].is_nullable);
assert_eq!(columns[1].name, "name");
assert_eq!(columns[1].ducklake_type, "varchar");
assert!(columns[1].is_nullable);
}
#[test]
fn test_build_schema_with_field_ids() {
let schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]);
let column_ids = vec![1, 2];
let schema_with_ids = build_schema_with_field_ids(&schema, &column_ids).unwrap();
let field0_metadata = schema_with_ids.field(0).metadata();
assert_eq!(
field0_metadata.get("PARQUET:field_id"),
Some(&"1".to_string())
);
let field1_metadata = schema_with_ids.field(1).metadata();
assert_eq!(
field1_metadata.get("PARQUET:field_id"),
Some(&"2".to_string())
);
}
#[test]
fn test_build_schema_with_nested_field_ids() {
let map = DataType::Map(
Arc::new(Field::new(
"entries",
DataType::Struct(
vec![
Arc::new(Field::new("key", DataType::Utf8, false)),
Arc::new(Field::new(
"value",
DataType::List(Arc::new(Field::new("item", DataType::Int32, true))),
true,
)),
]
.into(),
),
false,
)),
false,
);
let schema = Schema::new(vec![Field::new("attrs", map, true)]);
let schema = build_schema_with_field_ids(&schema, &[10, 11, 12, 13]).unwrap();
let root = schema.field(0);
assert_eq!(root.metadata().get("PARQUET:field_id"), Some(&"10".into()));
let DataType::Map(entries, false) = root.data_type() else {
panic!("expected map");
};
assert!(!entries.metadata().contains_key("PARQUET:field_id"));
let DataType::Struct(children) = entries.data_type() else {
panic!("expected entries struct");
};
assert_eq!(
children[0].metadata().get("PARQUET:field_id"),
Some(&"11".into())
);
assert_eq!(
children[1].metadata().get("PARQUET:field_id"),
Some(&"12".into())
);
let DataType::List(element) = children[1].data_type() else {
panic!("expected list value");
};
assert_eq!(
element.metadata().get("PARQUET:field_id"),
Some(&"13".into())
);
}
#[test]
fn test_apply_field_ids_rewrites_nested_field_metadata() {
let fields = vec![
Arc::new(Field::new("amount", DataType::Decimal128(38, 16), false)),
Arc::new(Field::new("currency", DataType::Utf8View, false)),
];
let values = StructArray::new(
fields.clone().into(),
vec![
Arc::new(
Decimal128Array::from(vec![1, 2])
.with_precision_and_scale(38, 16)
.unwrap(),
),
Arc::new(StringViewArray::from(vec!["USD", "EUR"])),
],
None,
);
let schema = Arc::new(Schema::new(vec![Field::new(
"money",
DataType::Struct(fields.into()),
false,
)]));
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(values)]).unwrap();
let schema_with_ids = Arc::new(build_schema_with_field_ids(&schema, &[1, 2, 3]).unwrap());
let rewritten = apply_field_ids(&batch, schema_with_ids.clone()).unwrap();
assert_eq!(rewritten.schema(), schema_with_ids);
let DataType::Struct(fields) = rewritten.column(0).data_type() else {
panic!("expected struct");
};
assert_eq!(
fields[0].metadata().get("PARQUET:field_id"),
Some(&"2".to_string())
);
assert_eq!(
fields[1].metadata().get("PARQUET:field_id"),
Some(&"3".to_string())
);
let mut writer = ArrowWriter::try_new(Vec::new(), schema_with_ids, None).unwrap();
writer.write(&rewritten).unwrap();
}
#[test]
fn build_schema_with_field_ids_rejects_missing_recursive_id() {
let schema = Schema::new(vec![Field::new(
"items",
DataType::List(Arc::new(Field::new("item", DataType::Int32, true))),
true,
)]);
let error = build_schema_with_field_ids(&schema, &[10]).unwrap_err();
assert_eq!(
error.to_string(),
"Internal error: Missing field id for Arrow field 'item' at recursive position 1",
);
}
#[test]
fn test_write_parquet_to_buffer_with_field_ids() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
],
)
.unwrap();
let column_ids = vec![10, 20];
let schema_with_ids = Arc::new(build_schema_with_field_ids(&schema, &column_ids).unwrap());
let props = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.build();
let mut writer =
ArrowWriter::try_new(Vec::new(), schema_with_ids.clone(), Some(props)).unwrap();
let batch_with_ids = apply_field_ids(&batch, schema_with_ids).unwrap();
writer.write(&batch_with_ids).unwrap();
let buffer = writer.into_inner().unwrap();
let file_size = buffer.len() as i64;
let footer_size = calculate_footer_size_from_bytes(&buffer).unwrap();
assert!(file_size > 0);
assert!(footer_size > 0);
assert!(footer_size < file_size);
}
#[test]
fn test_calculate_footer_size_from_bytes() {
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
let batch =
RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
let props = WriterProperties::builder()
.set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
.build();
let schema_with_ids = Arc::new(build_schema_with_field_ids(&batch.schema(), &[1]).unwrap());
let mut writer =
ArrowWriter::try_new(Vec::new(), schema_with_ids.clone(), Some(props)).unwrap();
let batch_with_ids = apply_field_ids(&batch, schema_with_ids).unwrap();
writer.write(&batch_with_ids).unwrap();
let buffer = writer.into_inner().unwrap();
let footer_size = calculate_footer_size_from_bytes(&buffer).unwrap();
assert!(footer_size >= 8);
assert!(footer_size < 10000);
}
}