use std::collections::HashSet;
use std::sync::Arc;
use arrow::array::{ArrayRef, Int64Array, RecordBatch};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::catalog::Session;
use crate::metadata_provider::DuckLakeTableFile;
use crate::metadata_writer::{CompactionOutputFile, CompactionSourceFile, SourceRetirement};
use crate::row_id::EMBEDDED_SNAPSHOT_ID_COLUMN_NAME;
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: 256 * 1024 * 1024,
max_merged_files: 1024,
min_file_size: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct RewriteOptions {
pub delete_threshold: f64,
}
impl Default for RewriteOptions {
fn default() -> Self {
Self {
delete_threshold: 0.95,
}
}
}
#[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)?)
}
impl DuckLakeTable {
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 mut candidates: Vec<&DuckLakeTableFile> = self
.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), 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 mut running: u64 = 0;
let mut bin: Vec<&DuckLakeTableFile> = Vec::new();
while i < candidates.len() && candidates[i].schema_version == version {
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 physical_schema = self.physical_schema();
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 file = table_writer
.write_compacted_file(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&merged,
partial,
)
.await?;
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 physical_schema = self.physical_schema();
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 tf in self.files() {
let record_count = tf.max_row_count.unwrap_or(0);
let delete_count = tf.delete_count.unwrap_or(0);
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 file = table_writer
.write_compacted_file(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&out.updated_batches,
false,
)
.await?;
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,
})
}
}