#![cfg(all(feature = "write-sqlite", feature = "metadata-sqlite"))]
use std::sync::Arc;
use arrow::array::{Array, Int32Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::prelude::*;
use object_store::local::LocalFileSystem;
use sqlx::Row;
use sqlx::sqlite::SqlitePool;
use tempfile::TempDir;
use datafusion_ducklake::maintenance::{CleanupCriteria, cleanup_old_files_sqlite};
use datafusion_ducklake::{
CompactionResult, DuckLakeCatalog, DuckLakeTable, DuckLakeTableWriter, MergeOptions,
MetadataWriter, RewriteOptions, SqliteMetadataProvider, SqliteMetadataWriter,
};
fn two_col_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("val", DataType::Int32, false),
]))
}
fn object_store() -> Arc<dyn object_store::ObjectStore> {
Arc::new(LocalFileSystem::new())
}
fn db_url(temp: &TempDir) -> String {
format!("sqlite:{}?mode=rwc", temp.path().join("test.db").display())
}
fn ro_url(temp: &TempDir) -> String {
format!("sqlite:{}", temp.path().join("test.db").display())
}
async fn make_writer(temp: &TempDir) -> SqliteMetadataWriter {
let data_path = temp.path().join("data");
std::fs::create_dir_all(&data_path).unwrap();
let writer = SqliteMetadataWriter::new_with_init(&db_url(temp))
.await
.unwrap();
writer.set_data_path(data_path.to_str().unwrap()).unwrap();
writer
}
fn batch(schema: Arc<Schema>, cols: Vec<Arc<dyn Array>>) -> RecordBatch {
RecordBatch::try_new(schema, cols).unwrap()
}
async fn seed(temp: &TempDir, ids: Vec<i32>, vals: Vec<i32>) {
let writer = Arc::new(make_writer(temp).await);
let b = batch(
two_col_schema(),
vec![Arc::new(Int32Array::from(ids)), Arc::new(Int32Array::from(vals))],
);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[b])
.await
.unwrap();
}
async fn append(temp: &TempDir, ids: Vec<i32>, vals: Vec<i32>) {
let writer = Arc::new(SqliteMetadataWriter::new(&db_url(temp)).await.unwrap());
let b = batch(
two_col_schema(),
vec![Arc::new(Int32Array::from(ids)), Arc::new(Int32Array::from(vals))],
);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.append_table("main", "t", &[b])
.await
.unwrap();
}
async fn pool(temp: &TempDir) -> SqlitePool {
SqlitePool::connect(&ro_url(temp)).await.unwrap()
}
async fn scalar_i64(p: &SqlitePool, sql: &str) -> i64 {
sqlx::query(sql)
.fetch_one(p)
.await
.unwrap()
.try_get::<i64, _>(0)
.unwrap()
}
async fn opt_i64(p: &SqlitePool, sql: &str) -> Option<i64> {
sqlx::query(sql)
.fetch_one(p)
.await
.unwrap()
.try_get::<Option<i64>, _>(0)
.unwrap()
}
async fn read_rows(temp: &TempDir) -> Vec<(i32, i32)> {
let provider = SqliteMetadataProvider::new(&ro_url(temp)).await.unwrap();
rows_via(DuckLakeCatalog::new(provider).unwrap()).await
}
async fn read_rows_at(temp: &TempDir, snapshot: i64) -> Vec<(i32, i32)> {
let provider = Arc::new(SqliteMetadataProvider::new(&ro_url(temp)).await.unwrap());
rows_via(DuckLakeCatalog::with_snapshot(provider, snapshot).unwrap()).await
}
async fn rows_via(catalog: DuckLakeCatalog) -> Vec<(i32, i32)> {
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
let batches = ctx
.sql("SELECT id, val FROM ducklake.main.t ORDER BY id, val")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut out = Vec::new();
for b in &batches {
let ids = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
let vals = b.column(1).as_any().downcast_ref::<Int32Array>().unwrap();
for i in 0..b.num_rows() {
out.push((ids.value(i), vals.value(i)));
}
}
out
}
async fn read_id_rowid(temp: &TempDir) -> Vec<(i32, i64)> {
let provider = SqliteMetadataProvider::new(&ro_url(temp)).await.unwrap();
let catalog = DuckLakeCatalog::new(provider)
.unwrap()
.with_row_lineage(true);
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
let batches = ctx
.sql("SELECT id, rowid FROM ducklake.main.t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut out = Vec::new();
for b in &batches {
let ids = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
let rids = b.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
for i in 0..b.num_rows() {
out.push((ids.value(i), rids.value(i)));
}
}
out
}
async fn with_writable_table<F, Fut>(temp: &TempDir, op: F) -> CompactionResult
where
F: FnOnce(DuckLakeTable, datafusion::execution::SessionState) -> Fut,
Fut: std::future::Future<Output = CompactionResult>,
{
let writer = SqliteMetadataWriter::new(&db_url(temp)).await.unwrap();
let provider = SqliteMetadataProvider::new(&db_url(temp)).await.unwrap();
let catalog = DuckLakeCatalog::with_writer(Arc::new(provider), Arc::new(writer)).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
let provider = ctx
.catalog("ducklake")
.unwrap()
.schema("main")
.unwrap()
.table("t")
.await
.unwrap()
.unwrap();
let table = (provider.as_ref() as &dyn std::any::Any)
.downcast_ref::<DuckLakeTable>()
.expect("provider is a DuckLakeTable")
.clone();
op(table, ctx.state()).await
}
async fn run_merge(temp: &TempDir, opts: MergeOptions) -> CompactionResult {
with_writable_table(temp, |table, state| async move {
table.merge_adjacent_files(&state, opts).await.unwrap()
})
.await
}
async fn run_rewrite(temp: &TempDir, opts: RewriteOptions) -> CompactionResult {
with_writable_table(temp, |table, state| async move {
table.rewrite_data_files(&state, opts).await.unwrap()
})
.await
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_harvests_output_stats_and_removes_source_stats() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1, 2, 3], vec![10, 20, 30]).await;
append(&temp, vec![4, 5, 6], vec![40, 50, 60]).await;
let p = pool(&temp).await;
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
2
);
assert!(
scalar_i64(&p, "SELECT COUNT(*) FROM ducklake_file_column_stats").await >= 2,
"each source file should have stats rows"
);
run_merge(&temp, MergeOptions::default()).await;
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_file_column_stats s
LEFT JOIN ducklake_data_file d ON d.data_file_id = s.data_file_id
WHERE d.data_file_id IS NULL",
)
.await,
0,
"merge must delete the retired sources' stats rows"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_file_column_stats s
JOIN ducklake_data_file d ON d.data_file_id = s.data_file_id
WHERE d.end_snapshot IS NULL AND s.min_value = '1' AND s.max_value = '6'",
)
.await,
1,
"merged file's id-column zone map should span the union of the sources"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_coalesces_small_files_preserving_results_rowids_and_time_travel() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1, 2], vec![10, 20]).await;
append(&temp, vec![3, 4], vec![30, 40]).await;
append(&temp, vec![5, 6], vec![50, 60]).await;
let p = pool(&temp).await;
let tid = scalar_i64(&p, "SELECT table_id FROM ducklake_table LIMIT 1").await;
let pre_snapshot = scalar_i64(&p, "SELECT MAX(snapshot_id) FROM ducklake_snapshot").await;
let snapshots_before = scalar_i64(&p, "SELECT COUNT(*) FROM ducklake_snapshot").await;
let live_before = scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
assert_eq!(live_before, 3, "three small files before merge");
let min_origin = scalar_i64(&p, "SELECT MIN(begin_snapshot) FROM ducklake_data_file").await;
let rows_before = read_rows(&temp).await;
let id_rowid_before = read_id_rowid(&temp).await;
assert_eq!(
rows_before,
vec![(1, 10), (2, 20), (3, 30), (4, 40), (5, 50), (6, 60)]
);
let result = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(result.files_processed, 3, "all three sources merged");
assert_eq!(result.files_created, 1, "into one file");
assert_eq!(result.rows_written, 6);
let snapshots_after = scalar_i64(&p, "SELECT COUNT(*) FROM ducklake_snapshot").await;
assert_eq!(
snapshots_after,
snapshots_before + 1,
"exactly one new snapshot"
);
let new_snapshot = scalar_i64(&p, "SELECT MAX(snapshot_id) FROM ducklake_snapshot").await;
assert_eq!(new_snapshot, pre_snapshot + 1);
let live_after = scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
assert_eq!(live_after, 1, "one live file after merge");
let partial_max = opt_i64(
&p,
"SELECT partial_max FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
assert_eq!(
partial_max,
Some(pre_snapshot),
"partial_max = max origin snapshot among merged rows"
);
let merged_row_id_start = opt_i64(
&p,
"SELECT row_id_start FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
assert_eq!(
merged_row_id_start, None,
"merged file serves rowids inline"
);
let merged_begin = scalar_i64(
&p,
"SELECT begin_snapshot FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
assert_eq!(
merged_begin, min_origin,
"merged partial file begins at the MIN origin snapshot (visible to history)"
);
let total_rows = scalar_i64(&p, "SELECT COUNT(*) FROM ducklake_data_file").await;
assert_eq!(
total_rows, 1,
"source rows removed; only the merged file remains"
);
let scheduled = scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion",
)
.await;
assert_eq!(scheduled, 3, "three source files scheduled for deletion");
let changes: String = sqlx::query(&format!(
"SELECT changes_made FROM ducklake_snapshot_changes WHERE snapshot_id = {new_snapshot}"
))
.fetch_one(&p)
.await
.unwrap()
.try_get(0)
.unwrap();
assert_eq!(changes, format!("compacted_table:{tid}"));
assert_eq!(
read_rows(&temp).await,
rows_before,
"results unchanged by merge"
);
assert_eq!(
read_id_rowid(&temp).await,
id_rowid_before,
"rowids preserved across merge"
);
assert_eq!(
read_rows_at(&temp, pre_snapshot).await,
rows_before,
"time travel to pre-merge snapshot returns the original rows"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn rewrite_drops_deleted_rows_and_retires_data_and_delete_files() {
let temp = TempDir::new().unwrap();
seed(
&temp,
(1..=10).collect(),
(1..=10).map(|v| v * 10).collect(),
)
.await;
let p = pool(&temp).await;
let tid = scalar_i64(&p, "SELECT table_id FROM ducklake_table LIMIT 1").await;
let create_snapshot = scalar_i64(&p, "SELECT MAX(snapshot_id) FROM ducklake_snapshot").await;
{
let writer = SqliteMetadataWriter::new(&db_url(&temp)).await.unwrap();
let provider = SqliteMetadataProvider::new(&db_url(&temp)).await.unwrap();
let catalog = DuckLakeCatalog::with_writer(Arc::new(provider), Arc::new(writer)).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
ctx.sql("DELETE FROM ducklake.main.t WHERE id <= 8")
.await
.unwrap()
.collect()
.await
.unwrap();
}
let after_delete_snapshot =
scalar_i64(&p, "SELECT MAX(snapshot_id) FROM ducklake_snapshot").await;
assert_eq!(
read_rows(&temp).await,
vec![(9, 90), (10, 100)],
"8 of 10 deleted"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_delete_file WHERE end_snapshot IS NULL"
)
.await,
1
);
let result = run_rewrite(
&temp,
RewriteOptions {
delete_threshold: 0.5,
},
)
.await;
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_created, 1);
assert_eq!(result.rows_written, 2, "only the two live rows rewritten");
let new_snapshot = scalar_i64(&p, "SELECT MAX(snapshot_id) FROM ducklake_snapshot").await;
assert_eq!(
new_snapshot,
after_delete_snapshot + 1,
"exactly one new snapshot"
);
assert_eq!(read_rows(&temp).await, vec![(9, 90), (10, 100)]);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
1
);
assert_eq!(
scalar_i64(
&p,
"SELECT record_count FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
2,
"new file contains only the live rows"
);
assert_eq!(
opt_i64(
&p,
"SELECT partial_max FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
None,
"a rewrite output is not a partial file"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_delete_file WHERE end_snapshot IS NULL"
)
.await,
0,
"no live delete file after rewrite"
);
assert_eq!(
scalar_i64(
&p,
&format!("SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot = {new_snapshot}")
)
.await,
1
);
assert_eq!(
scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_delete_file WHERE end_snapshot = {new_snapshot}"
)
)
.await,
1
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
0,
"rewrite sources are retained (not scheduled) for time travel"
);
let changes: String = sqlx::query(&format!(
"SELECT changes_made FROM ducklake_snapshot_changes WHERE snapshot_id = {new_snapshot}"
))
.fetch_one(&p)
.await
.unwrap()
.try_get(0)
.unwrap();
assert_eq!(changes, format!("compacted_table:{tid}"));
assert_eq!(read_id_rowid(&temp).await, vec![(9, 8), (10, 9)]);
assert_eq!(read_rows_at(&temp, create_snapshot).await.len(), 10);
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_respects_schema_version_boundary() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1, 2], vec![10, 20]).await;
append(&temp, vec![3, 4], vec![30, 40]).await;
{
let three_col = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("val", DataType::Int32, false),
Field::new("note", DataType::Int32, true),
]));
let writer = Arc::new(SqliteMetadataWriter::new(&db_url(&temp)).await.unwrap());
let b = batch(
three_col,
vec![
Arc::new(Int32Array::from(vec![5, 6])),
Arc::new(Int32Array::from(vec![50, 60])),
Arc::new(Int32Array::from(vec![500, 600])),
],
);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.append_table("main", "t", &[b])
.await
.unwrap();
}
let p = pool(&temp).await;
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
3
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(DISTINCT schema_version) FROM ducklake_schema_versions"
)
.await,
2,
"a DDL bumped the schema version"
);
let v2_file = scalar_i64(
&p,
"SELECT MAX(data_file_id) FROM ducklake_data_file WHERE end_snapshot IS NULL",
)
.await;
let result = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(result.files_processed, 2, "only the two v1 files merged");
assert_eq!(result.files_created, 1);
assert_eq!(
scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_data_file \
WHERE data_file_id = {v2_file} AND end_snapshot IS NULL"
)
)
.await,
1,
"the newer-schema-version file was not merged"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
2,
"only the two v1 source files scheduled"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
2
);
assert_eq!(
read_rows(&temp).await,
vec![(1, 10), (2, 20), (3, 30), (4, 40), (5, 50), (6, 60)]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_never_remerges_a_partial_file() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1], vec![10]).await; append(&temp, vec![2], vec![20]).await; append(&temp, vec![3], vec![30]).await;
let r1 = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(r1.files_created, 1);
let p = pool(&temp).await;
assert_eq!(
opt_i64(
&p,
"SELECT partial_max FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
Some(3),
"merge produced a partial file"
);
append(&temp, vec![4], vec![40]).await;
let r2 = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(
r2.files_processed, 0,
"the partial file P is not a candidate; D alone cannot merge"
);
assert_eq!(
read_rows_at(&temp, 2).await,
vec![(1, 10), (2, 20)],
"time travel intact: no origins collapsed by a re-merge"
);
assert_eq!(
read_rows(&temp).await,
vec![(1, 10), (2, 20), (3, 30), (4, 40)]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_skips_files_whose_columns_were_dropped() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1, 2], vec![10, 20]).await; append(&temp, vec![3, 4], vec![30, 40]).await;
{
let id_only = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
let writer = Arc::new(SqliteMetadataWriter::new(&db_url(&temp)).await.unwrap());
let b = batch(id_only, vec![Arc::new(Int32Array::from(vec![5]))]);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.append_table("main", "t", &[b])
.await
.unwrap();
}
let p = pool(&temp).await;
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
3,
"three live files (A, B at v1; C at v2)"
);
let result = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(
result.files_processed, 0,
"column-dropping files are not merged"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
0,
"nothing merged, nothing scheduled"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_data_file WHERE end_snapshot IS NULL"
)
.await,
3,
"all three files remain live (A, B not removed)"
);
assert_eq!(read_rows_at(&temp, 1).await, vec![(1, 10), (2, 20)]);
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_partial_file_serves_time_travel_after_sources_are_deleted() {
let temp = TempDir::new().unwrap();
seed(&temp, vec![1, 2], vec![10, 20]).await; append(&temp, vec![3, 4], vec![30, 40]).await; append(&temp, vec![5, 6], vec![50, 60]).await;
let p = pool(&temp).await;
let result = run_merge(&temp, MergeOptions::default()).await;
assert_eq!(result.files_processed, 3);
assert_eq!(result.files_created, 1);
let deleted = {
let writer = SqliteMetadataWriter::new(&db_url(&temp)).await.unwrap();
cleanup_old_files_sqlite(&writer, object_store(), CleanupCriteria::All, false)
.await
.unwrap()
};
assert_eq!(
deleted.len(),
3,
"three source parquet files physically deleted"
);
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
0,
"scheduled rows cleared after cleanup"
);
assert_eq!(
read_rows_at(&temp, 1).await,
vec![(1, 10), (2, 20)],
"as of snapshot 1: only the first insert"
);
assert_eq!(
read_rows_at(&temp, 2).await,
vec![(1, 10), (2, 20), (3, 30), (4, 40)],
"as of snapshot 2: first two inserts"
);
assert_eq!(
read_rows_at(&temp, 3).await,
vec![(1, 10), (2, 20), (3, 30), (4, 40), (5, 50), (6, 60)],
"as of snapshot 3: all three inserts"
);
assert_eq!(read_rows(&temp).await.len(), 6);
}