use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use arrow::array::{ArrayRef, Int64Array, RecordBatch};
use arrow::compute::SortOptions;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::catalog::Session;
use datafusion::datasource::memory::MemorySourceConfig;
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr, expressions::Column};
use datafusion::physical_plan::{ExecutionPlan, sorts::sort::SortExec};
use crate::column_rename::ColumnRenameExec;
use crate::metadata_provider::DuckLakeTableFile;
use crate::metadata_writer::{CompactionOutputFile, CompactionSourceFile, SourceRetirement};
use crate::partition::PartitionSpec;
use crate::row_id::EMBEDDED_SNAPSHOT_ID_COLUMN_NAME;
use crate::sort::{SortDirection, SortSpec};
use crate::table::DuckLakeTable;
use crate::table_writer::DuckLakeTableWriter;
use crate::{DuckLakeError, Result};
#[derive(Debug, Clone)]
pub struct MergeOptions {
pub target_file_size: u64,
pub max_merged_files: usize,
pub min_file_size: u64,
}
impl Default for MergeOptions {
fn default() -> Self {
Self {
target_file_size: crate::table_writer::DEFAULT_TARGET_FILE_SIZE as u64,
max_merged_files: 1024,
min_file_size: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct RewriteOptions {
pub delete_threshold: f64,
pub data_file_ids: Option<Vec<i64>>,
}
impl Default for RewriteOptions {
fn default() -> Self {
Self {
delete_threshold: 0.95,
data_file_ids: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionResult {
pub files_processed: usize,
pub files_created: usize,
pub rows_written: i64,
}
impl CompactionResult {
fn empty() -> Self {
Self {
files_processed: 0,
files_created: 0,
rows_written: 0,
}
}
pub fn did_work(&self) -> bool {
self.files_processed > 0
}
}
fn append_snapshot_column(batch: &RecordBatch, origin: i64) -> Result<RecordBatch> {
let n = batch.num_rows();
let snap: ArrayRef = Arc::new(Int64Array::from(vec![origin; n]));
let mut cols: Vec<ArrayRef> = batch.columns().to_vec();
cols.push(snap);
let mut fields: Vec<Field> = batch
.schema()
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect();
fields.push(Field::new(
EMBEDDED_SNAPSHOT_ID_COLUMN_NAME,
DataType::Int64,
true,
));
Ok(RecordBatch::try_new(Arc::new(Schema::new(fields)), cols)?)
}
fn partition_key(file: &DuckLakeTableFile) -> (Option<i64>, Vec<Option<String>>) {
let mut values = file.partition_values.clone();
values.sort_by_key(|(index, _)| *index);
(
file.partition_id,
values.into_iter().map(|(_, value)| value).collect(),
)
}
fn partition_value_pairs(values: &[Option<String>]) -> Vec<(i32, Option<String>)> {
values
.iter()
.enumerate()
.map(|(index, value)| (index as i32, value.clone()))
.collect()
}
pub(crate) fn sorted_rewrite_output(
context: Arc<TaskContext>,
batches: Vec<RecordBatch>,
data_schema: &Schema,
sort_spec: Option<&SortSpec>,
) -> Result<SendableRecordBatchStream> {
let schema = batches
.first()
.ok_or_else(|| DuckLakeError::Internal("cannot sort empty compaction input".to_string()))?
.schema();
let input = MemorySourceConfig::try_new_exec(&[batches], Arc::clone(&schema), None)?;
let Some(sort_spec) = sort_spec else {
return Ok(input.execute(0, Arc::clone(&context))?);
};
let keys = sort_spec.producible_columns().ok_or_else(|| {
DuckLakeError::InvalidConfig(format!(
"DuckLake sort order {} contains an unsupported expression; \
datafusion-ducklake can write only bare-column sort keys",
sort_spec.sort_id
))
})?;
if keys.is_empty() {
return Ok(input.execute(0, Arc::clone(&context))?);
}
let mut expressions = Vec::with_capacity(keys.len());
for (name, direction, null_order) in keys {
let index = data_schema.index_of(&name).map_err(|_| {
DuckLakeError::InvalidConfig(format!(
"DuckLake sort key '{name}' is not present in the rewrite schema"
))
})?;
expressions.push(PhysicalSortExpr::new(
Arc::new(Column::new(&name, index)),
SortOptions {
descending: direction == SortDirection::Desc,
nulls_first: null_order.nulls_first(),
},
));
}
let ordering = LexOrdering::new(expressions)
.ok_or_else(|| DuckLakeError::Internal("sort order is empty".to_string()))?;
let sorted: Arc<dyn ExecutionPlan> = Arc::new(SortExec::new(ordering, input));
let output = Arc::new(ColumnRenameExec::new(sorted, schema, HashMap::new()));
Ok(output.execute(0, context)?)
}
impl DuckLakeTable {
#[cfg(feature = "write")]
fn partition_path_names(
&self,
live: Option<&PartitionSpec>,
partition_id: i64,
column_ids: &[i64],
) -> Vec<String> {
let Some(spec) = live.filter(|spec| spec.partition_id == partition_id) else {
return Vec::new();
};
let schema = self.physical_schema();
let names: Option<Vec<String>> = spec
.columns
.iter()
.map(|column| {
let index = column_ids.iter().position(|id| *id == column.column_id)?;
Some(schema.field(index).name().to_string())
})
.collect();
names.unwrap_or_default()
}
pub async fn merge_adjacent_files(
&self,
state: &dyn Session,
opts: MergeOptions,
) -> Result<CompactionResult> {
let writer = self.writer().ok_or_else(|| {
DuckLakeError::InvalidConfig(
"merge_adjacent_files: table is read-only; open the catalog with a writer"
.to_string(),
)
})?;
let schema_name = self.schema_name().ok_or_else(|| {
DuckLakeError::Internal("writable table has no schema name".to_string())
})?;
let table_files = self.files()?;
let mut candidates: Vec<&DuckLakeTableFile> = table_files
.iter()
.filter(|f| {
f.delete_file_id.is_none()
&& f.partial_max.is_none()
&& f.begin_snapshot.is_some()
&& f.schema_version.is_some()
&& (f.file.file_size_bytes as u64) >= opts.min_file_size
&& (f.file.file_size_bytes as u64) < opts.target_file_size
})
.collect();
candidates.sort_by_key(|f| {
(
f.schema_version.unwrap_or(0),
partition_key(f),
f.data_file_id,
)
});
candidates.truncate(opts.max_merged_files);
let mut bins: Vec<Vec<&DuckLakeTableFile>> = Vec::new();
let mut i = 0;
while i < candidates.len() {
let version = candidates[i].schema_version;
let partition = partition_key(candidates[i]);
let mut running: u64 = 0;
let mut bin: Vec<&DuckLakeTableFile> = Vec::new();
while i < candidates.len()
&& candidates[i].schema_version == version
&& partition_key(candidates[i]) == partition
{
bin.push(candidates[i]);
running += candidates[i].file.file_size_bytes as u64;
i += 1;
if running >= opts.target_file_size {
break;
}
}
if bin.len() >= 2 {
bins.push(bin);
}
}
if bins.is_empty() {
return Ok(CompactionResult::empty());
}
let object_store = state
.runtime_env()
.object_store(self.object_store_url().as_ref())?;
let table_writer = DuckLakeTableWriter::new(Arc::clone(writer), object_store)?;
let column_ids = self.column_ids();
let top_level_column_ids = self.top_level_column_ids();
let physical_schema = self.physical_schema();
let sort_spec = self.live_sort_spec()?;
let live_partition_spec = self.live_partition_spec()?;
let mut sources: Vec<CompactionSourceFile> = Vec::new();
let mut outputs: Vec<CompactionOutputFile> = Vec::new();
let mut files_processed = 0usize;
let mut rows_written = 0i64;
for bin in &bins {
let mut bin_would_drop_columns = false;
for tf in bin {
if self.file_drops_current_columns(state, &tf.file).await? {
bin_would_drop_columns = true;
break;
}
}
if bin_would_drop_columns {
continue;
}
let mut per_source: Vec<(Vec<RecordBatch>, i64)> = Vec::with_capacity(bin.len());
for tf in bin {
let scan = self.build_update_scan(state, tf).await?;
let batches =
datafusion::physical_plan::collect(Arc::clone(&scan.scan), state.task_ctx())
.await?;
let out = self.apply_update_to_batches(&scan, &batches, None, &[])?;
let origin = tf.begin_snapshot.ok_or_else(|| {
DuckLakeError::Internal("merge candidate missing begin_snapshot".to_string())
})?;
rows_written += out.matched_count as i64;
per_source.push((out.updated_batches, origin));
sources.push(CompactionSourceFile {
data_file_id: tf.data_file_id,
delete_file_id: None,
});
files_processed += 1;
}
let origins: HashSet<i64> = per_source.iter().map(|(_, o)| *o).collect();
let partial = origins.len() > 1;
let min_origin = origins.iter().copied().min();
let partial_max = if partial {
origins.iter().copied().max()
} else {
None
};
let mut merged: Vec<RecordBatch> = Vec::new();
for (batches, origin) in per_source {
for b in batches {
if b.num_rows() == 0 {
continue;
}
merged.push(if partial {
append_snapshot_column(&b, origin)?
} else {
b
});
}
}
if merged.is_empty() {
continue;
}
let merged = sorted_rewrite_output(
state.task_ctx(),
merged,
physical_schema.as_ref(),
sort_spec.as_ref(),
)?;
let (partition_id, partition_values) = partition_key(bin[0]);
let subpath = partition_id.map(|pid| {
let names = self.partition_path_names(
live_partition_spec.as_ref(),
pid,
&top_level_column_ids,
);
crate::partition::hive_subpath(&names, &partition_values)
});
let file = table_writer
.write_compacted_file_stream(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&top_level_column_ids,
merged,
partial,
subpath.as_deref(),
)
.await?;
let file = match partition_id {
Some(pid) => file.with_partition(pid, partition_value_pairs(&partition_values)),
None => file,
};
outputs.push(CompactionOutputFile {
file,
partial_max,
begin_snapshot: min_origin,
});
}
if sources.is_empty() {
return Ok(CompactionResult::empty());
}
writer.commit_compaction(
self.table_id(),
self.base_snapshot(),
&sources,
&outputs,
SourceRetirement::Remove,
)?;
Ok(CompactionResult {
files_processed,
files_created: outputs.len(),
rows_written,
})
}
pub async fn rewrite_data_files(
&self,
state: &dyn Session,
opts: RewriteOptions,
) -> Result<CompactionResult> {
if !(0.0..=1.0).contains(&opts.delete_threshold) {
return Err(DuckLakeError::InvalidConfig(format!(
"rewrite_data_files: delete_threshold must be in [0.0, 1.0], got {}",
opts.delete_threshold
)));
}
let writer = self.writer().ok_or_else(|| {
DuckLakeError::InvalidConfig(
"rewrite_data_files: table is read-only; open the catalog with a writer"
.to_string(),
)
})?;
let schema_name = self.schema_name().ok_or_else(|| {
DuckLakeError::Internal("writable table has no schema name".to_string())
})?;
let object_store = state
.runtime_env()
.object_store(self.object_store_url().as_ref())?;
let table_writer = DuckLakeTableWriter::new(Arc::clone(writer), object_store)?;
let column_ids = self.column_ids();
let top_level_column_ids = self.top_level_column_ids();
let physical_schema = self.physical_schema();
let sort_spec = self.live_sort_spec()?;
let live_partition_spec = self.live_partition_spec()?;
let mut sources: Vec<CompactionSourceFile> = Vec::new();
let mut outputs: Vec<CompactionOutputFile> = Vec::new();
let mut files_processed = 0usize;
let mut rows_written = 0i64;
let selected_ids = opts
.data_file_ids
.map(|ids| ids.into_iter().collect::<HashSet<_>>());
let table_files = self.files()?;
for tf in &table_files {
let record_count = tf.max_row_count.unwrap_or(0);
let delete_count = tf.delete_count.unwrap_or(0);
if let Some(selected_ids) = &selected_ids {
if !selected_ids.contains(&tf.data_file_id) {
continue;
}
} else {
if tf.delete_file_id.is_none() || record_count <= 0 {
continue;
}
let ratio = delete_count as f64 / record_count as f64;
if ratio < opts.delete_threshold {
continue;
}
}
let scan = self.build_update_scan(state, tf).await?;
let batches =
datafusion::physical_plan::collect(Arc::clone(&scan.scan), state.task_ctx())
.await?;
let out = self.apply_update_to_batches(&scan, &batches, None, &[])?;
files_processed += 1;
sources.push(CompactionSourceFile {
data_file_id: tf.data_file_id,
delete_file_id: tf.delete_file_id,
});
let live_rows = out.matched_count;
if live_rows > 0 {
let sorted = sorted_rewrite_output(
state.task_ctx(),
out.updated_batches,
physical_schema.as_ref(),
sort_spec.as_ref(),
)?;
let (partition_id, partition_values) = partition_key(tf);
let subpath = partition_id.map(|pid| {
let names = self.partition_path_names(
live_partition_spec.as_ref(),
pid,
&top_level_column_ids,
);
crate::partition::hive_subpath(&names, &partition_values)
});
let file = table_writer
.write_compacted_file_stream(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&top_level_column_ids,
sorted,
false,
subpath.as_deref(),
)
.await?;
let file = match partition_id {
Some(pid) => file.with_partition(pid, partition_value_pairs(&partition_values)),
None => file,
};
rows_written += live_rows as i64;
outputs.push(CompactionOutputFile {
file,
partial_max: None,
begin_snapshot: None,
});
}
}
if sources.is_empty() {
return Ok(CompactionResult::empty());
}
writer.commit_compaction(
self.table_id(),
self.base_snapshot(),
&sources,
&outputs,
SourceRetirement::Retire,
)?;
Ok(CompactionResult {
files_processed,
files_created: outputs.len(),
rows_written,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sort::{DUCKDB_DIALECT, NullOrder, SortDirection, SortField};
use arrow::array::Int64Array;
use datafusion::prelude::SessionContext;
#[test]
fn sorted_rewrite_output_rejects_expression_sort_key() {
let data_schema = Schema::new(vec![Field::new("id", DataType::Int64, false)]);
let batch = RecordBatch::try_new(
Arc::new(data_schema.clone()),
vec![Arc::new(Int64Array::from(vec![2, 1]))],
)
.unwrap();
let sort_spec = SortSpec {
sort_id: 7,
fields: vec![SortField {
sort_key_index: 0,
expression: "lower(id)".to_string(),
dialect: DUCKDB_DIALECT.to_string(),
direction: SortDirection::Asc,
null_order: NullOrder::NullsLast,
}],
};
let result = sorted_rewrite_output(
SessionContext::new().task_ctx(),
vec![batch],
&data_schema,
Some(&sort_spec),
);
let err = match result {
Ok(_) => panic!("expression sort key must be rejected"),
Err(e) => e,
};
assert_eq!(
err.to_string(),
"Invalid configuration: DuckLake sort order 7 contains an unsupported expression; \
datafusion-ducklake can write only bare-column sort keys",
);
}
}