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::buffered::BufWriter as ObjectBufWriter;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, ObjectStoreExt};
use parquet::arrow::ArrowWriter;
use parquet::basic::{BrotliLevel, Compression, GzipLevel, ZstdLevel};
use parquet::file::properties::{WriterProperties, WriterVersion};
use tempfile::NamedTempFile;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use crate::Result;
use crate::metadata_provider::DuckLakeInlinedData;
use crate::metadata_writer::{
ColumnDef, DataFileInfo, DeleteFileEntry, DeleteFileInfo, InlinedRowRef, MetadataWriter,
SnapshotCommitMetadata, StagedTableData, StagedTableWrite, 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 DEFAULT_UPLOAD_CONCURRENCY: usize = 4;
pub const MINIMUM_TARGET_FILE_SIZE: usize = 4096;
pub const DEFAULT_TARGET_FILE_SIZE: usize = 1 << 29;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DuckLakeWriteOptions {
pub data_inlining_row_limit: Option<usize>,
pub compression: Option<Compression>,
pub parquet_version: Option<WriterVersion>,
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>,
pub upload_concurrency: Option<usize>,
pub sort_on_insert: Option<bool>,
pub hive_file_pattern: Option<bool>,
pub auto_compact: Option<bool>,
pub rewrite_delete_threshold: Option<f64>,
deferred_error: Option<String>,
}
impl DuckLakeWriteOptions {
pub(crate) fn from_metadata_settings(settings: &HashMap<String, String>) -> Result<Self> {
let compression_name = settings
.get("parquet_compression")
.map(String::as_str)
.unwrap_or("snappy");
let compression_level = compression_name
.eq_ignore_ascii_case("zstd")
.then(|| {
setting_i32(settings, "parquet_compression_level").map(|level| level.or(Some(3)))
})
.transpose()?
.flatten();
Ok(Self {
data_inlining_row_limit: setting_usize(settings, "data_inlining_row_limit", Some(0))?,
compression: Some(setting_compression(compression_name, compression_level)?),
parquet_version: setting_parquet_version(settings)?,
max_row_group_rows: setting_usize(settings, "parquet_row_group_size", Some(122_880))?,
max_row_group_bytes: setting_size(settings, "parquet_row_group_size_bytes", None)?,
target_file_size: setting_size(
settings,
"target_file_size",
Some(DEFAULT_TARGET_FILE_SIZE),
)?,
max_open_partitions: None,
upload_concurrency: None,
sort_on_insert: setting_bool(settings, "sort_on_insert", true)?,
hive_file_pattern: setting_bool(settings, "hive_file_pattern", true)?,
auto_compact: setting_bool(settings, "auto_compact", true)?,
rewrite_delete_threshold: setting_f64(
settings,
"rewrite_delete_threshold",
Some(0.95),
)?,
deferred_error: None,
})
}
pub(crate) fn from_metadata_settings_deferred(settings: &HashMap<String, String>) -> Self {
Self::from_metadata_settings(settings).unwrap_or_else(|e| Self {
deferred_error: Some(e.to_string()),
..Self::default()
})
}
#[must_use]
pub fn with_data_inlining_row_limit(mut self, limit: usize) -> Self {
self.data_inlining_row_limit = Some(limit);
self
}
pub(crate) fn validate(&self) -> Result<()> {
match &self.deferred_error {
Some(error) => Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid DuckLake write settings: {error}"
))),
None => Ok(()),
}
}
pub(crate) fn with_overrides(mut self, overrides: &Self) -> Self {
if overrides.data_inlining_row_limit.is_some() {
self.data_inlining_row_limit = overrides.data_inlining_row_limit;
}
if overrides.compression.is_some() {
self.compression = overrides.compression;
}
if overrides.parquet_version.is_some() {
self.parquet_version = overrides.parquet_version;
}
if overrides.max_row_group_rows.is_some() {
self.max_row_group_rows = overrides.max_row_group_rows;
}
if overrides.max_row_group_bytes.is_some() {
self.max_row_group_bytes = overrides.max_row_group_bytes;
}
if overrides.target_file_size.is_some() {
self.target_file_size = overrides.target_file_size;
}
if overrides.max_open_partitions.is_some() {
self.max_open_partitions = overrides.max_open_partitions;
}
if overrides.upload_concurrency.is_some() {
self.upload_concurrency = overrides.upload_concurrency;
}
if overrides.sort_on_insert.is_some() {
self.sort_on_insert = overrides.sort_on_insert;
}
if overrides.hive_file_pattern.is_some() {
self.hive_file_pattern = overrides.hive_file_pattern;
}
if overrides.auto_compact.is_some() {
self.auto_compact = overrides.auto_compact;
}
if overrides.rewrite_delete_threshold.is_some() {
self.rewrite_delete_threshold = overrides.rewrite_delete_threshold;
}
self
}
}
fn setting_usize(
settings: &HashMap<String, String>,
key: &str,
default: Option<usize>,
) -> Result<Option<usize>> {
settings
.get(key)
.map(|value| {
value.parse::<usize>().map_err(|e| {
crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} value '{value}': {e}"
))
})
})
.transpose()
.map(|value| value.or(default))
}
fn setting_i32(settings: &HashMap<String, String>, key: &str) -> Result<Option<i32>> {
settings
.get(key)
.map(|value| {
value.parse::<i32>().map_err(|e| {
crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} value '{value}': {e}"
))
})
})
.transpose()
}
fn setting_f64(
settings: &HashMap<String, String>,
key: &str,
default: Option<f64>,
) -> Result<Option<f64>> {
let value = settings
.get(key)
.map(|value| {
value.parse::<f64>().map_err(|e| {
crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} value '{value}': {e}"
))
})
})
.transpose()?
.or(default);
if value.is_some_and(|value| !(0.0..=1.0).contains(&value)) {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"ducklake_metadata {key} must be in [0.0, 1.0]"
)));
}
Ok(value)
}
fn setting_parquet_version(settings: &HashMap<String, String>) -> Result<Option<WriterVersion>> {
settings
.get("parquet_version")
.map(|value| match value.to_ascii_lowercase().as_str() {
"1" | "v1" => Ok(WriterVersion::PARQUET_1_0),
"2" | "v2" => Ok(WriterVersion::PARQUET_2_0),
_ => Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata parquet_version '{value}'; expected 1, 2, V1, or V2"
))),
})
.transpose()
}
fn setting_size(
settings: &HashMap<String, String>,
key: &str,
default: Option<usize>,
) -> Result<Option<usize>> {
settings
.get(key)
.map(|value| parse_size(key, value))
.transpose()
.map(|value| value.or(default))
}
fn parse_size(key: &str, value: &str) -> Result<usize> {
let value = value.trim();
let digits = value.bytes().take_while(u8::is_ascii_digit).count();
let (number, unit) = value.split_at(digits);
if number.is_empty() {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} size '{value}'"
)));
}
let number = number.parse::<usize>().map_err(|e| {
crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} size '{value}': {e}"
))
})?;
let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1,
"kb" => 1_000,
"mb" => 1_000_000,
"gb" => 1_000_000_000,
"tb" => 1_000_000_000_000,
"kib" => 1 << 10,
"mib" => 1 << 20,
"gib" => 1 << 30,
"tib" => 1usize << 40,
_ => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} size unit in '{value}'"
)));
},
};
number.checked_mul(multiplier).ok_or_else(|| {
crate::error::DuckLakeError::InvalidConfig(format!(
"ducklake_metadata {key} size '{value}' exceeds usize"
))
})
}
fn setting_bool(
settings: &HashMap<String, String>,
key: &str,
default: bool,
) -> Result<Option<bool>> {
let value = match settings.get(key) {
Some(value) => match value.to_ascii_lowercase().as_str() {
"true" => true,
"false" => false,
_ => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata {key} value '{value}'; expected true or false"
)));
},
},
None => default,
};
Ok(Some(value))
}
fn setting_compression(name: &str, level: Option<i32>) -> Result<Compression> {
let invalid = |e: parquet::errors::ParquetError| {
crate::error::DuckLakeError::InvalidConfig(format!(
"Invalid ducklake_metadata parquet_compression_level {}: {e}",
level.unwrap_or_default()
))
};
match name.to_ascii_lowercase().as_str() {
"uncompressed" => Ok(Compression::UNCOMPRESSED),
"snappy" => Ok(Compression::SNAPPY),
"gzip" => Ok(Compression::GZIP(GzipLevel::default())),
"brotli" => Ok(Compression::BROTLI(BrotliLevel::default())),
"zstd" => Ok(Compression::ZSTD(match level {
None | Some(0) => ZstdLevel::default(),
Some(level) => ZstdLevel::try_new(level).map_err(invalid)?,
})),
"lz4" | "lz4_raw" => Ok(Compression::LZ4_RAW),
_ => Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Unsupported ducklake_metadata parquet_compression '{name}'"
))),
}
}
#[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)]
struct PreparedTableWrite {
write: StagedTableWrite,
object_paths: Vec<ObjectPath>,
files_written: usize,
records_written: i64,
}
#[derive(Debug)]
pub struct DuckLakeWriteTransaction<'a> {
writer: &'a DuckLakeTableWriter,
writes: Vec<PreparedTableWrite>,
commit_metadata: SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct DuckLakeTableWriter {
metadata: Arc<dyn MetadataWriter>,
object_store: Arc<dyn ObjectStore>,
base_key_path: String,
compression: Compression,
writer_version: WriterVersion,
max_row_group_rows: Option<usize>,
max_row_group_bytes: Option<usize>,
upload_concurrency: usize,
target_file_size: usize,
max_open_partitions: usize,
data_inlining_row_limit: Option<usize>,
sort_on_insert: bool,
hive_file_pattern: bool,
}
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,
writer_version: WriterVersion::PARQUET_2_0,
max_row_group_rows: None,
max_row_group_bytes: None,
upload_concurrency: DEFAULT_UPLOAD_CONCURRENCY,
target_file_size: DEFAULT_TARGET_FILE_SIZE,
max_open_partitions: DEFAULT_MAX_OPEN_PARTITIONS,
data_inlining_row_limit: None,
sort_on_insert: true,
hive_file_pattern: true,
})
}
#[must_use]
pub fn transaction(&self) -> DuckLakeWriteTransaction<'_> {
DuckLakeWriteTransaction {
writer: self,
writes: Vec::new(),
commit_metadata: SnapshotCommitMetadata::default(),
expected_base_snapshot_id: None,
}
}
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
}
#[must_use]
pub fn with_upload_concurrency(mut self, files: usize) -> Self {
self.upload_concurrency = files.max(1);
self
}
pub fn with_options(mut self, options: &DuckLakeWriteOptions) -> Self {
if let Some(limit) = options.data_inlining_row_limit {
self.data_inlining_row_limit = Some(limit);
}
if let Some(compression) = options.compression {
self.compression = compression;
}
if let Some(writer_version) = options.parquet_version {
self.writer_version = writer_version;
}
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);
}
if let Some(files) = options.upload_concurrency {
self.upload_concurrency = files.max(1);
}
if let Some(sort_on_insert) = options.sort_on_insert {
self.sort_on_insert = sort_on_insert;
}
if let Some(hive_file_pattern) = options.hive_file_pattern {
self.hive_file_pattern = hive_file_pattern;
}
self
}
fn build_writer_props(&self) -> WriterProperties {
let mut builder = WriterProperties::builder()
.set_writer_version(self.writer_version)
.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(self.writer_version)
.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,
upload_concurrency: self.upload_concurrency,
hive_file_pattern: self.hive_file_pattern,
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(),
upload_concurrency: self.upload_concurrency,
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
}
pub async fn flush_inlined_data(
&self,
schema_name: &str,
table_name: &str,
inlined_data: &[DuckLakeInlinedData],
expected_base_snapshot_id: i64,
) -> Result<Option<WriteResult>> {
if inlined_data.is_empty() {
return Ok(None);
}
let mut batches = Vec::with_capacity(inlined_data.len());
let mut rows = Vec::new();
for inlined in inlined_data {
if inlined.row_ids.len() != inlined.batch.num_rows()
|| inlined.begin_snapshots.len() != inlined.batch.num_rows()
{
return Err(crate::DuckLakeError::InvalidConfig(format!(
"inlined row identities for '{}' do not match its {} batch rows",
inlined.table_name,
inlined.batch.num_rows(),
)));
}
batches.push(inlined.batch.clone());
rows.extend(inlined.row_ids.iter().map(|row_id| InlinedRowRef {
table_name: inlined.table_name.clone(),
row_id: *row_id,
}));
}
let schema = batches[0].schema();
if batches
.iter()
.any(|batch| !batch.schema().contains(schema.as_ref()))
{
return Err(crate::DuckLakeError::InvalidConfig(
"inlined flush batches do not share one compatible schema".to_string(),
));
}
let write_options = DuckLakeWriteOptions {
data_inlining_row_limit: Some(0),
target_file_size: Some(usize::MAX),
..DuckLakeWriteOptions::default()
};
let flush_writer = self.clone().with_options(&write_options);
let transaction_options =
TableWriteOptions::new().with_expected_base_snapshot_id(expected_base_snapshot_id);
let mut transaction = flush_writer
.transaction()
.with_options(&transaction_options);
transaction
.stage_write_with_deletes(
schema_name,
table_name,
schema.as_ref(),
WriteMode::Append,
&batches,
&[],
&rows,
)
.await?;
transaction
.writes
.last_mut()
.expect("flush stages one table")
.write
.inlined_flush = true;
let mut results = transaction.commit().await?;
if results.len() != 1 {
return Err(crate::DuckLakeError::Internal(format!(
"inlined flush committed {} table results, expected 1",
results.len(),
)));
}
Ok(results.pop())
}
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(self.writer_version)
.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);
stream_to_upload(&mut reader, &mut upload).await?;
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(self.writer_version)
.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);
stream_to_upload(&mut reader, &mut upload).await?;
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 records_written: usize = groups
.iter()
.flat_map(|(_, batches)| batches)
.map(RecordBatch::num_rows)
.sum();
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)?;
}
} else {
return Err(crate::DuckLakeError::Conflict(
"partition spec changed before write; retry the write".to_string(),
));
}
let batches: Vec<RecordBatch> = groups
.iter()
.flat_map(|(_, batches)| batches.iter().cloned())
.collect();
if self.should_inline(records_written, arrow_schema, &batches) {
let committed = self.metadata.register_inlined_data(
setup.table_id,
schema_name,
table_name,
setup.snapshot_id,
&batches,
mode,
setup.base_snapshot_id,
&columns,
&setup.field_ids,
&options.commit_metadata,
options.expected_base_snapshot_id,
)?;
return Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: 0,
records_written: records_written as i64,
});
}
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 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 records_written: usize = batches.iter().map(RecordBatch::num_rows).sum();
if !resolve_layout && self.metadata.live_partition_spec(setup.table_id)?.is_some() {
return Err(crate::DuckLakeError::Conflict(
"table gained a partition spec before write; retry the write".to_string(),
));
}
if self.should_inline(records_written, arrow_schema, batches) {
let committed = self.metadata.register_inlined_data(
setup.table_id,
schema_name,
table_name,
setup.snapshot_id,
batches,
mode,
setup.base_snapshot_id,
&columns,
&setup.field_ids,
&SnapshotCommitMetadata::new(),
None,
)?;
return Ok(WriteResult {
snapshot_id: committed.snapshot_id,
table_id: committed.table_id,
schema_id: committed.schema_id,
files_written: 0,
records_written: records_written as i64,
});
}
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 && self.sort_on_insert {
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 && self.sort_on_insert {
&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,
})
}
async fn prepare_rows_inner(
&self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
resolve_layout: bool,
) -> Result<PreparedTableWrite> {
let columns = arrow_schema_to_column_defs(arrow_schema)?;
let setup =
self.metadata
.begin_write_transaction(schema_name, table_name, &columns, mode)?;
let records_written: usize = batches.iter().map(RecordBatch::num_rows).sum();
if records_written == 0 {
return Err(crate::error::DuckLakeError::InvalidConfig(
"multi-table row stage requires at least one row".to_string(),
));
}
if !resolve_layout && self.metadata.live_partition_spec(setup.table_id)?.is_some() {
return Err(crate::DuckLakeError::Conflict(
"table gained a partition spec before write; retry the write".to_string(),
));
}
if self.should_inline(records_written, arrow_schema, batches) {
return Ok(PreparedTableWrite {
write: StagedTableWrite {
table_id: setup.table_id,
schema_name: schema_name.to_string(),
table_name: table_name.to_string(),
base_snapshot_id: setup.base_snapshot_id,
mode,
columns,
column_ids: setup.field_ids,
data: StagedTableData::Inlined(batches.to_vec()),
snapshot_id_columns: Vec::new(),
positional_deletes: Vec::new(),
inlined_deletes: Vec::new(),
inlined_flush: false,
},
object_paths: Vec::new(),
files_written: 0,
records_written: records_written as i64,
});
}
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 = if resolve_layout && self.sort_on_insert {
let lengths = batches
.iter()
.map(RecordBatch::num_rows)
.collect::<Vec<_>>();
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 = if resolve_layout && self.sort_on_insert {
&sorted_owned
} else {
batches
};
let file_infos = match partition.as_ref() {
Some(spec) => {
let output_schema = 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?
},
};
let object_paths = file_infos
.iter()
.map(|file| {
self.staged_object_path(schema_name, table_name, &file.path, file.path_is_relative)
})
.collect::<Result<Vec<_>>>()?;
let records_written = file_infos.iter().map(|file| file.record_count).sum();
let files_written = file_infos.len();
Ok(PreparedTableWrite {
write: StagedTableWrite {
table_id: setup.table_id,
schema_name: schema_name.to_string(),
table_name: table_name.to_string(),
base_snapshot_id: setup.base_snapshot_id,
mode,
columns,
column_ids: setup.field_ids,
data: StagedTableData::Files(file_infos),
snapshot_id_columns: Vec::new(),
positional_deletes: Vec::new(),
inlined_deletes: Vec::new(),
inlined_flush: false,
},
object_paths,
files_written,
records_written,
})
}
fn staged_object_path(
&self,
schema_name: &str,
table_name: &str,
path: &str,
path_is_relative: bool,
) -> Result<ObjectPath> {
let path = if path_is_relative {
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)?;
join_paths(&table_key, path)?
} else {
path.to_string()
};
Ok(ObjectPath::from(path.trim_start_matches('/')))
}
fn should_inline(&self, rows: usize, arrow_schema: &Schema, batches: &[RecordBatch]) -> bool {
rows > 0
&& self
.data_inlining_row_limit
.is_some_and(|limit| rows <= limit)
&& self.metadata.supports_data_inlining(arrow_schema)
&& self.metadata.supports_data_inlining_values(batches)
}
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 = if self.hive_file_pattern {
crate::partition::hive_subpath(key_names, values)
} else {
String::new()
};
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)
}
}
impl DuckLakeWriteTransaction<'_> {
#[must_use]
pub fn with_options(mut self, options: &TableWriteOptions) -> Self {
self.commit_metadata = options.commit_metadata.clone();
self.expected_base_snapshot_id = options.expected_base_snapshot_id;
self
}
pub async fn stage_write(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
) -> Result<()> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return Ok(());
}
let prepared = self
.writer
.prepare_rows_inner(schema_name, table_name, arrow_schema, mode, batches, true)
.await?;
self.writes.push(prepared);
Ok(())
}
pub async fn stage_write_with_options(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
options: &DuckLakeWriteOptions,
) -> Result<()> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return Ok(());
}
let writer = self.writer.clone().with_options(options);
let prepared = writer
.prepare_rows_inner(schema_name, table_name, arrow_schema, mode, batches, true)
.await?;
self.writes.push(prepared);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn stage_write_with_snapshot_columns(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
options: &DuckLakeWriteOptions,
snapshot_id_columns: &[&str],
) -> Result<()> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return Ok(());
}
let writer = self.writer.clone().with_options(options);
if !writer.should_inline(
batches.iter().map(RecordBatch::num_rows).sum(),
arrow_schema,
batches,
) {
return Err(crate::DuckLakeError::InvalidConfig(
"commit snapshot columns require an inlined table stage".to_string(),
));
}
for name in snapshot_id_columns {
if !arrow_schema
.fields()
.iter()
.any(|field| field.name() == name)
{
return Err(crate::DuckLakeError::InvalidConfig(format!(
"commit snapshot column '{name}' is not present in the staged schema"
)));
}
}
let mut prepared = writer
.prepare_rows_inner(schema_name, table_name, arrow_schema, mode, batches, true)
.await?;
prepared.write.snapshot_id_columns = snapshot_id_columns
.iter()
.map(|name| (*name).to_string())
.collect();
self.writes.push(prepared);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn stage_write_with_deletes_and_snapshot_columns(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
positional_deletes: &[DeleteFileEntry],
inlined_deletes: &[InlinedRowRef],
options: &DuckLakeWriteOptions,
snapshot_id_columns: &[&str],
) -> Result<()> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return self.stage_deletes(
schema_name,
table_name,
arrow_schema,
positional_deletes,
inlined_deletes,
);
}
if !positional_deletes.is_empty() {
validate_delete_entries(mode, positional_deletes)?;
}
let delete_paths = positional_deletes
.iter()
.map(|entry| {
self.writer.staged_object_path(
schema_name,
table_name,
&entry.delete.path,
entry.delete.path_is_relative,
)
})
.collect::<Result<Vec<_>>>()?;
self.stage_write_with_snapshot_columns(
schema_name,
table_name,
arrow_schema,
mode,
batches,
options,
snapshot_id_columns,
)
.await?;
let prepared = self
.writes
.last_mut()
.expect("snapshot-column stage appended one table");
prepared.object_paths.extend(delete_paths);
prepared.write.positional_deletes = positional_deletes.to_vec();
prepared.write.inlined_deletes = inlined_deletes.to_vec();
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn stage_write_with_deletes(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
mode: WriteMode,
batches: &[RecordBatch],
positional_deletes: &[DeleteFileEntry],
inlined_deletes: &[InlinedRowRef],
) -> Result<()> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return self.stage_deletes(
schema_name,
table_name,
arrow_schema,
positional_deletes,
inlined_deletes,
);
}
if !positional_deletes.is_empty() {
validate_delete_entries(mode, positional_deletes)?;
}
let delete_paths = positional_deletes
.iter()
.map(|entry| {
self.writer.staged_object_path(
schema_name,
table_name,
&entry.delete.path,
entry.delete.path_is_relative,
)
})
.collect::<Result<Vec<_>>>()?;
self.stage_write(schema_name, table_name, arrow_schema, mode, batches)
.await?;
let prepared = self
.writes
.last_mut()
.expect("stage_write appended one table");
prepared.object_paths.extend(delete_paths);
prepared.write.positional_deletes = positional_deletes.to_vec();
prepared.write.inlined_deletes = inlined_deletes.to_vec();
Ok(())
}
pub fn stage_deletes(
&mut self,
schema_name: &str,
table_name: &str,
arrow_schema: &Schema,
positional_deletes: &[DeleteFileEntry],
inlined_deletes: &[InlinedRowRef],
) -> Result<()> {
validate_delete_entries(WriteMode::Append, positional_deletes)?;
if positional_deletes.is_empty() && inlined_deletes.is_empty() {
return Ok(());
}
let columns = arrow_schema_to_column_defs(arrow_schema)?;
let setup = self.writer.metadata.begin_write_transaction(
schema_name,
table_name,
&columns,
WriteMode::Append,
)?;
let object_paths = positional_deletes
.iter()
.map(|entry| {
self.writer.staged_object_path(
schema_name,
table_name,
&entry.delete.path,
entry.delete.path_is_relative,
)
})
.collect::<Result<Vec<_>>>()?;
self.writes.push(PreparedTableWrite {
write: StagedTableWrite {
table_id: setup.table_id,
schema_name: schema_name.to_string(),
table_name: table_name.to_string(),
base_snapshot_id: setup.base_snapshot_id,
mode: WriteMode::Append,
columns,
column_ids: setup.field_ids,
data: StagedTableData::None,
snapshot_id_columns: Vec::new(),
positional_deletes: positional_deletes.to_vec(),
inlined_deletes: inlined_deletes.to_vec(),
inlined_flush: false,
},
object_paths,
files_written: 0,
records_written: 0,
});
Ok(())
}
pub async fn commit(mut self) -> Result<Vec<WriteResult>> {
if self.writes.is_empty() {
return Ok(Vec::new());
}
let writes = self
.writes
.iter()
.map(|prepared| prepared.write.clone())
.collect::<Vec<_>>();
let committed = self.writer.metadata.commit_multi_table(
&writes,
&self.commit_metadata,
self.expected_base_snapshot_id,
);
let committed = match committed {
Ok(committed) => committed,
Err(e) => {
let definitely_rolled_back = matches!(
e,
crate::error::DuckLakeError::Conflict(_)
| crate::error::DuckLakeError::InvalidConfig(_)
| crate::error::DuckLakeError::Unsupported(_)
);
if definitely_rolled_back && let Err(cleanup) = self.cleanup().await {
return Err(crate::error::DuckLakeError::Internal(format!(
"multi-table commit failed: {e}; staged-file cleanup failed: {cleanup}"
)));
}
return Err(e);
},
};
if committed.tables.len() != self.writes.len() {
return Err(crate::error::DuckLakeError::Internal(format!(
"multi-table commit returned {} table ids for {} stages",
committed.tables.len(),
self.writes.len()
)));
}
Ok(self
.writes
.iter()
.zip(committed.tables)
.map(|(prepared, table)| WriteResult {
snapshot_id: committed.snapshot_id,
table_id: table.table_id,
schema_id: table.schema_id,
files_written: prepared.files_written,
records_written: prepared.records_written,
})
.collect())
}
pub async fn abort(mut self) -> Result<()> {
self.cleanup().await
}
async fn cleanup(&mut self) -> Result<()> {
let mut failures = Vec::new();
for path in self
.writes
.iter()
.flat_map(|prepared| prepared.object_paths.iter())
{
if let Err(e) = self.writer.object_store.delete(path).await {
failures.push(format!("{path}: {e}"));
}
}
if !failures.is_empty() {
return Err(crate::error::DuckLakeError::Internal(format!(
"failed to remove staged files: {}",
failures.join("; ")
)));
}
Ok(())
}
}
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());
stream_to_upload(&mut reader, &mut upload).await?;
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,
hive_file_pattern: bool,
open: Vec<(Vec<Option<String>>, RollingFileWriter)>,
upload_concurrency: usize,
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 = if self.hive_file_pattern {
crate::partition::hive_subpath(&self.key_names, values)
} else {
String::new()
};
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 (values, staged): (Vec<_>, Vec<_>) =
std::mem::take(&mut self.staged).into_iter().unzip();
let uploaded = upload_staged_files_ordered(
staged,
object_store,
&self.column_ids,
self.upload_concurrency,
)
.await?;
let infos = values
.into_iter()
.zip(uploaded)
.map(|(values, info)| {
let partition_values: Vec<(i32, Option<String>)> = values
.into_iter()
.enumerate()
.map(|(i, v)| (i as i32, v))
.collect();
info.with_partition(self.spec.partition_id, partition_values)
})
.collect();
Ok(infos)
}
}
#[tracing::instrument(
name = "ducklake.upload_staged_files",
level = "info",
skip_all,
fields(files = staged.len(), concurrency)
)]
async fn upload_staged_files_ordered(
staged: Vec<StagedFile>,
object_store: &Arc<dyn ObjectStore>,
column_ids: &[i64],
concurrency: usize,
) -> Result<Vec<DataFileInfo>> {
let targets: Vec<ObjectPath> = staged.iter().map(|s| s.object_path.clone()).collect();
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let results: Vec<Option<Result<DataFileInfo>>> =
futures::stream::iter(staged.into_iter().map(|s| {
let failed = Arc::clone(&failed);
async move {
if failed.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
let outcome = upload_staged_file(s, object_store, column_ids).await;
if outcome.is_err() {
failed.store(true, std::sync::atomic::Ordering::Relaxed);
}
Some(outcome)
}
}))
.buffered(concurrency.max(1))
.collect()
.await;
let mut infos = Vec::with_capacity(results.len());
let mut failure = None;
for (index, result) in results.into_iter().enumerate() {
let Some(result) = result else {
continue;
}; match result {
Ok(info) => infos.push(info),
Err(e) => {
if failure.is_none() {
failure = Some(e);
} else {
tracing::warn!(
error = %e,
file_index = index,
"additional upload failure in the same batch"
);
}
},
}
}
if let Some(e) = failure {
let to_delete = futures::stream::iter(targets.into_iter().map(Ok));
let mut deletions = object_store.delete_stream(to_delete.boxed());
while let Some(outcome) = deletions.next().await {
match outcome {
Ok(_)
| Err(object_store::Error::NotFound {
..
}) => {},
Err(cleanup) => tracing::warn!(
error = %cleanup,
"failed to remove a data file after an aborted upload batch"
),
}
}
return Err(e);
}
debug_assert_eq!(
infos.len(),
targets.len(),
"a skipped upload must imply a returned error"
);
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>,
upload_concurrency: usize,
commit_metadata: SnapshotCommitMetadata,
}
impl TableWriteSession {
pub(crate) const fn with_base_snapshot_id(mut self, snapshot_id: i64) -> Self {
self.base_snapshot_id = snapshot_id;
self
}
#[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 file_infos = upload_staged_files_ordered(
std::mem::take(&mut self.rolled),
&self.object_store,
&self.column_ids,
self.upload_concurrency,
)
.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 {
upload_staged_files_ordered(
std::mem::take(&mut self.rolled),
&self.object_store,
&self.column_ids,
self.upload_concurrency,
)
.await?
}
} 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());
stream_to_upload(&mut reader, &mut upload).await?;
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,
{
if let Err(e) = tokio::io::copy(reader, upload).await {
let _ = upload.abort().await;
return Err(e);
}
upload.shutdown().await
}
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;
use rstest::rstest;
#[rstest]
#[case("gzip", "22", Compression::GZIP(GzipLevel::default()))]
#[case("brotli", "12", Compression::BROTLI(BrotliLevel::default()))]
#[case("snappy", "-1", Compression::SNAPPY)]
fn compression_level_only_applies_to_zstd(
#[case] codec: &str,
#[case] level: &str,
#[case] expected: Compression,
) {
let settings = HashMap::from([
("parquet_compression".to_string(), codec.to_string()),
("parquet_compression_level".to_string(), level.to_string()),
]);
let options = DuckLakeWriteOptions::from_metadata_settings(&settings).unwrap();
assert_eq!(options.compression, Some(expected));
}
#[rstest]
fn zstd_zero_uses_the_parquet_default_level() {
let settings = HashMap::from([
("parquet_compression".to_string(), "zstd".to_string()),
("parquet_compression_level".to_string(), "0".to_string()),
]);
let options = DuckLakeWriteOptions::from_metadata_settings(&settings).unwrap();
assert_eq!(
options.compression,
Some(Compression::ZSTD(ZstdLevel::default()))
);
}
#[rstest]
fn zstd_without_a_level_uses_the_ducklake_default() {
let settings = HashMap::from([("parquet_compression".to_string(), "zstd".to_string())]);
let options = DuckLakeWriteOptions::from_metadata_settings(&settings).unwrap();
assert_eq!(
options.compression,
Some(Compression::ZSTD(ZstdLevel::try_new(3).unwrap()))
);
}
#[rstest]
fn invalid_write_setting_is_deferred_until_validation() {
let settings = HashMap::from([
("parquet_compression".to_string(), "zstd".to_string()),
("parquet_compression_level".to_string(), "23".to_string()),
]);
let options = DuckLakeWriteOptions::from_metadata_settings_deferred(&settings);
assert!(options.validate().is_err());
}
#[rstest]
fn metadata_options_include_parquet_version_and_rewrite_threshold() {
let settings = HashMap::from([
("parquet_version".to_string(), "V1".to_string()),
("rewrite_delete_threshold".to_string(), "0.75".to_string()),
]);
let options = DuckLakeWriteOptions::from_metadata_settings(&settings).unwrap();
assert_eq!(options.parquet_version, Some(WriterVersion::PARQUET_1_0));
assert_eq!(options.rewrite_delete_threshold, Some(0.75));
}
#[test]
fn metadata_write_options_apply_ducklake_defaults_and_units() {
let settings = HashMap::from([
("parquet_compression".to_string(), "zstd".to_string()),
("parquet_compression_level".to_string(), "5".to_string()),
(
"parquet_row_group_size_bytes".to_string(),
"2 MiB".to_string(),
),
("target_file_size".to_string(), "5MB".to_string()),
("sort_on_insert".to_string(), "false".to_string()),
("hive_file_pattern".to_string(), "false".to_string()),
("auto_compact".to_string(), "false".to_string()),
]);
let options = DuckLakeWriteOptions::from_metadata_settings(&settings).unwrap();
assert_eq!(
options.compression,
Some(Compression::ZSTD(ZstdLevel::try_new(5).unwrap()))
);
assert_eq!(options.data_inlining_row_limit, Some(0));
assert_eq!(options.max_row_group_rows, Some(122_880));
assert_eq!(options.max_row_group_bytes, Some(2 * 1_048_576));
assert_eq!(options.target_file_size, Some(5_000_000));
assert_eq!(options.max_open_partitions, None);
assert_eq!(options.sort_on_insert, Some(false));
assert_eq!(options.hive_file_pattern, Some(false));
assert_eq!(options.auto_compact, Some(false));
}
#[test]
fn explicit_write_options_override_catalog_settings_per_field() {
let stored = DuckLakeWriteOptions::from_metadata_settings(&HashMap::from([
("parquet_compression".to_string(), "zstd".to_string()),
("target_file_size".to_string(), "5MB".to_string()),
]))
.unwrap();
let explicit = DuckLakeWriteOptions {
compression: Some(Compression::LZ4_RAW),
sort_on_insert: Some(false),
..Default::default()
};
let options = stored.with_overrides(&explicit);
assert_eq!(options.compression, Some(Compression::LZ4_RAW));
assert_eq!(options.data_inlining_row_limit, Some(0));
assert_eq!(options.target_file_size, Some(5_000_000));
assert_eq!(options.sort_on_insert, Some(false));
assert_eq!(options.hive_file_pattern, Some(true));
}
#[test]
fn metadata_lz4_uses_the_standard_parquet_codec() {
let options = DuckLakeWriteOptions::from_metadata_settings(&HashMap::from([(
"parquet_compression".to_string(),
"lz4".to_string(),
)]))
.unwrap();
assert_eq!(options.compression, Some(Compression::LZ4_RAW));
}
#[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);
}
}