#![cfg(all(feature = "write-sqlite", feature = "metadata-sqlite"))]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use object_store::ObjectStore;
use object_store::local::LocalFileSystem;
use sqlx::Row;
use sqlx::sqlite::SqlitePool;
use tempfile::TempDir;
use datafusion_ducklake::SqliteMetadataWriter;
use datafusion_ducklake::maintenance::{
CleanupCriteria, ExpireCriteria, cleanup_old_files_sqlite, delete_orphaned_files_sqlite,
};
use datafusion_ducklake::metadata_writer::{ColumnDef, DataFileInfo, MetadataWriter, WriteMode};
fn cols() -> Vec<ColumnDef> {
vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
]
}
struct Harness {
writer: SqliteMetadataWriter,
conn_str: String,
data_path: PathBuf,
_temp: TempDir,
}
async fn setup() -> Harness {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("test.db");
let data_path = temp.path().join("data");
std::fs::create_dir_all(&data_path).unwrap();
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let writer = SqliteMetadataWriter::new_with_init(&conn_str)
.await
.unwrap();
writer.set_data_path(data_path.to_str().unwrap()).unwrap();
Harness {
writer,
conn_str,
data_path,
_temp: temp,
}
}
async fn pool(h: &Harness) -> SqlitePool {
SqlitePool::connect(&h.conn_str).await.unwrap()
}
async fn scalar_i64(pool: &SqlitePool, sql: &str) -> i64 {
sqlx::query(sql)
.fetch_one(pool)
.await
.unwrap()
.try_get::<i64, _>(0)
.unwrap()
}
fn write_physical_file(data_path: &Path, schema: &str, table: &str, name: &str) {
let dir = data_path.join(schema).join(table);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), b"parquet-bytes").unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn drop_table_tombstones_children_and_is_idempotent() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "users", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s.table_id,
s.snapshot_id,
&DataFileInfo::new("f1.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s.column_ids,
)
.unwrap();
let dropped = h.writer.drop_table("main", "users").unwrap();
assert!(dropped, "live table should drop");
let p = pool(&h).await;
for tbl in ["ducklake_table", "ducklake_column", "ducklake_data_file"] {
let live = scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM {tbl} WHERE table_id = {} AND end_snapshot IS NULL",
s.table_id
),
)
.await;
assert_eq!(live, 0, "no live rows in {tbl} after drop");
}
let stats = scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_table_stats WHERE table_id = {}",
s.table_id
),
)
.await;
assert_eq!(stats, 1, "table_stats row preserved across drop");
let next_row_id = scalar_i64(
&p,
&format!(
"SELECT next_row_id FROM ducklake_table_stats WHERE table_id = {}",
s.table_id
),
)
.await;
assert_eq!(next_row_id, 5, "next_row_id preserved");
let dropped_again = h.writer.drop_table("main", "users").unwrap();
assert!(!dropped_again, "second drop returns false");
}
#[tokio::test(flavor = "multi_thread")]
async fn drop_table_unknown_and_empty_names() {
let h = setup().await;
assert!(
!h.writer.drop_table("main", "ghost").unwrap(),
"unknown table -> false"
);
assert!(
h.writer.drop_table("", "users").is_err(),
"empty schema rejected"
);
assert!(
h.writer.drop_table("main", "").is_err(),
"empty table rejected"
);
}
fn three_generations(writer: &SqliteMetadataWriter) -> (i64, i64, i64, i64) {
let s1 = writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
writer
.register_data_file(
s1.table_id,
s1.snapshot_id,
&DataFileInfo::new("f1.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s1.column_ids,
)
.unwrap();
let s2 = writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
writer
.register_data_file(
s2.table_id,
s2.snapshot_id,
&DataFileInfo::new("f2.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s2.column_ids,
)
.unwrap();
let s3 = writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
writer
.register_data_file(
s3.table_id,
s3.snapshot_id,
&DataFileInfo::new("f3.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s3.column_ids,
)
.unwrap();
(s1.table_id, s1.snapshot_id, s2.snapshot_id, s3.snapshot_id)
}
#[tokio::test(flavor = "multi_thread")]
async fn expire_by_version_schedules_orphaned_file() {
let h = setup().await;
let (_tid, s1, _s2, _s3) = three_generations(&h.writer);
let expired = h
.writer
.expire_snapshots(ExpireCriteria::Versions(vec![s1]))
.unwrap();
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].snapshot_id, s1);
let p = pool(&h).await;
assert_eq!(
scalar_i64(
&p,
&format!("SELECT COUNT(*) FROM ducklake_snapshot WHERE snapshot_id = {s1}")
)
.await,
0,
"expired snapshot row removed"
);
let scheduled: Vec<(String, i64)> =
sqlx::query("SELECT path, path_is_relative FROM ducklake_files_scheduled_for_deletion")
.fetch_all(&p)
.await
.unwrap()
.into_iter()
.map(|r| {
(
r.try_get::<String, _>(0).unwrap(),
r.try_get::<i64, _>(1).unwrap(),
)
})
.collect();
assert_eq!(scheduled.len(), 1);
assert_eq!(scheduled[0].0, "main/t/f1.parquet");
assert_eq!(scheduled[0].1, 1, "path is relative to data_path");
assert_eq!(
scalar_i64(&p, "SELECT COUNT(*) FROM ducklake_data_file").await,
2
);
}
#[tokio::test(flavor = "multi_thread")]
async fn expire_full_after_drop_reclaims_all_table_metadata() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s.table_id,
s.snapshot_id,
&DataFileInfo::new("f1.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s.column_ids,
)
.unwrap();
assert!(h.writer.drop_table("main", "t").unwrap());
let expired = h
.writer
.expire_snapshots(ExpireCriteria::Versions(vec![s.snapshot_id]))
.unwrap();
assert_eq!(expired.len(), 1);
let p = pool(&h).await;
let tid = s.table_id;
for tbl in ["ducklake_table", "ducklake_column", "ducklake_data_file", "ducklake_table_stats"] {
let cnt = scalar_i64(
&p,
&format!("SELECT COUNT(*) FROM {tbl} WHERE table_id = {tid}"),
)
.await;
assert_eq!(cnt, 0, "{tbl} fully reclaimed after expire");
}
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
1
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_old_files_deletes_physical_file() {
let h = setup().await;
let (_tid, s1, _s2, _s3) = three_generations(&h.writer);
for name in ["f1.parquet", "f2.parquet", "f3.parquet"] {
write_physical_file(&h.data_path, "main", "t", name);
}
h.writer
.expire_snapshots(ExpireCriteria::Versions(vec![s1]))
.unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let f1 = h.data_path.join("main").join("t").join("f1.parquet");
let dry = cleanup_old_files_sqlite(&h.writer, store.clone(), CleanupCriteria::All, true)
.await
.unwrap();
assert_eq!(dry.len(), 1);
assert!(f1.exists(), "dry run must not delete");
let done = cleanup_old_files_sqlite(&h.writer, store.clone(), CleanupCriteria::All, false)
.await
.unwrap();
assert_eq!(done.len(), 1);
assert!(!f1.exists(), "f1 physically removed");
assert!(
h.data_path
.join("main")
.join("t")
.join("f2.parquet")
.exists(),
"live file f2 untouched"
);
let p = pool(&h).await;
assert_eq!(
scalar_i64(
&p,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion"
)
.await,
0,
"scheduled rows cleared"
);
let again = cleanup_old_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert!(again.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn expire_older_than_uses_datetime_utc() {
let h = setup().await;
let (_tid, _s1, _s2, _s3) = three_generations(&h.writer);
let cutoff = chrono::Utc::now() + chrono::Duration::days(1);
let expired = h
.writer
.expire_snapshots(ExpireCriteria::OlderThan(cutoff))
.unwrap();
assert_eq!(expired.len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn expire_and_cleanup_no_op_paths() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
let expired = h
.writer
.expire_snapshots(ExpireCriteria::Versions(vec![s.snapshot_id]))
.unwrap();
assert!(
expired.is_empty(),
"cannot expire the only/most-recent snapshot"
);
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let cleaned = cleanup_old_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert!(cleaned.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_removes_unreferenced_keeps_referenced() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s.table_id,
s.snapshot_id,
&DataFileInfo::new("referenced.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s.column_ids,
)
.unwrap();
write_physical_file(&h.data_path, "main", "t", "referenced.parquet");
write_physical_file(&h.data_path, "main", "t", "orphan.parquet");
std::fs::write(
h.data_path.join("main").join("t").join("README.txt"),
b"keep me",
)
.unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let orphan_path = h.data_path.join("main").join("t").join("orphan.parquet");
let ref_path = h
.data_path
.join("main")
.join("t")
.join("referenced.parquet");
let readme = h.data_path.join("main").join("t").join("README.txt");
let dry = delete_orphaned_files_sqlite(&h.writer, store.clone(), CleanupCriteria::All, true)
.await
.unwrap();
assert_eq!(dry.len(), 1);
assert!(
dry[0].ends_with("main/t/orphan.parquet"),
"got {:?}",
dry[0]
);
assert!(orphan_path.exists());
let done = delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert_eq!(done.len(), 1);
assert!(!orphan_path.exists(), "orphan should be gone");
assert!(ref_path.exists(), "referenced file must survive");
assert!(readme.exists(), "non-parquet file must be ignored");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_older_than_skips_recent_files() {
let h = setup().await;
write_physical_file(&h.data_path, "main", "t", "fresh_orphan.parquet");
let fresh = h
.data_path
.join("main")
.join("t")
.join("fresh_orphan.parquet");
assert!(fresh.exists());
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let cutoff = chrono::Utc::now() - chrono::Duration::hours(1);
let deleted =
delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::OlderThan(cutoff), false)
.await
.unwrap();
assert!(
deleted.is_empty(),
"files newer than cutoff must not be deleted (got {deleted:?})"
);
assert!(fresh.exists(), "fresh orphan survives older_than filter");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_spares_files_pending_in_scheduled_table() {
let h = setup().await;
write_physical_file(&h.data_path, "main", "t", "scheduled.parquet");
let p = pool(&h).await;
sqlx::query(
"INSERT INTO ducklake_files_scheduled_for_deletion
(data_file_id, path, path_is_relative, schedule_start)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)",
)
.bind(42_i64)
.bind("main/t/scheduled.parquet")
.execute(&p)
.await
.unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let deleted = delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert!(
deleted.is_empty(),
"files in scheduled-for-deletion must be considered referenced (got {deleted:?})"
);
assert!(
h.data_path
.join("main")
.join("t")
.join("scheduled.parquet")
.exists()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_handles_empty_schema_path() {
let h = setup().await;
let p = pool(&h).await;
sqlx::query("INSERT INTO ducklake_snapshot (snapshot_time) VALUES (CURRENT_TIMESTAMP)")
.execute(&p)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES ('s', '', 1, 1)",
)
.execute(&p)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (1, 't', 't', 1, 1)",
)
.execute(&p)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_data_file (table_id, path, path_is_relative, file_size_bytes, record_count, begin_snapshot)
VALUES (1, 'f.parquet', 1, 100, 5, 1)",
)
.execute(&p)
.await
.unwrap();
let dir = h.data_path.join("t");
std::fs::create_dir_all(&dir).unwrap();
let referenced = dir.join("f.parquet");
std::fs::write(&referenced, b"data").unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let deleted = delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert!(
deleted.is_empty(),
"referenced file under empty-schema-path resolution must survive (got {deleted:?})"
);
assert!(referenced.exists(), "referenced file must still exist");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_handles_absolute_file_paths() {
let h = setup().await;
let p = pool(&h).await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
write_physical_file(&h.data_path, "main", "t", "abs.parquet");
let abs_file = h.data_path.join("main").join("t").join("abs.parquet");
let abs_str = abs_file.to_str().unwrap().to_string();
sqlx::query(
"INSERT INTO ducklake_data_file (table_id, path, path_is_relative, file_size_bytes, record_count, begin_snapshot)
VALUES (?, ?, 0, 100, 5, ?)",
)
.bind(s.table_id)
.bind(&abs_str)
.bind(s.snapshot_id)
.execute(&p)
.await
.unwrap();
write_physical_file(&h.data_path, "main", "t", "stray.parquet");
let stray = h.data_path.join("main").join("t").join("stray.parquet");
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let deleted = delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap();
assert_eq!(
deleted.len(),
1,
"stray is the only orphan (got {deleted:?})"
);
assert!(
deleted[0].ends_with("main/t/stray.parquet"),
"got {:?}",
deleted[0]
);
assert!(!stray.exists(), "stray deleted");
assert!(abs_file.exists(), "absolute-path registered file survives");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_recurses_into_nested_directories() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s.table_id,
s.snapshot_id,
&DataFileInfo::new("ref.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s.column_ids,
)
.unwrap();
write_physical_file(&h.data_path, "main", "t", "ref.parquet");
let deep_dir = h
.data_path
.join("main")
.join("t")
.join("year=2024")
.join("month=01");
std::fs::create_dir_all(&deep_dir).unwrap();
let level2 = deep_dir.join("partition.parquet");
std::fs::write(&level2, b"orphan-2").unwrap();
let deeper_dir = deep_dir.join("day=15");
std::fs::create_dir_all(&deeper_dir).unwrap();
let level3 = deeper_dir.join("nested.parquet");
std::fs::write(&level3, b"orphan-3").unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let deleted: HashSet<String> =
delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap()
.into_iter()
.collect();
assert_eq!(
deleted.len(),
2,
"both nested orphans must be discovered (got {deleted:?})"
);
assert!(!level2.exists());
assert!(!level3.exists());
assert!(
h.data_path
.join("main")
.join("t")
.join("ref.parquet")
.exists(),
"referenced survives"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_dry_run_matches_real_run() {
let h = setup().await;
let s = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s.table_id,
s.snapshot_id,
&DataFileInfo::new("ref.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s.column_ids,
)
.unwrap();
write_physical_file(&h.data_path, "main", "t", "ref.parquet");
for name in ["o1.parquet", "o2.parquet", "o3.parquet"] {
write_physical_file(&h.data_path, "main", "t", name);
}
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let dry: HashSet<String> =
delete_orphaned_files_sqlite(&h.writer, store.clone(), CleanupCriteria::All, true)
.await
.unwrap()
.into_iter()
.collect();
assert_eq!(dry.len(), 3);
for name in ["o1.parquet", "o2.parquet", "o3.parquet"] {
assert!(h.data_path.join("main").join("t").join(name).exists());
}
let real: HashSet<String> =
delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::All, false)
.await
.unwrap()
.into_iter()
.collect();
assert_eq!(
dry, real,
"dry_run and real_run must return identical path sets"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_orphaned_files_all_on_empty_catalog_wipes_data_path() {
let h = setup().await;
write_physical_file(&h.data_path, "main", "t", "innocent.parquet");
write_physical_file(&h.data_path, "main", "t", "innocent2.parquet");
let innocent1 = h.data_path.join("main").join("t").join("innocent.parquet");
let innocent2 = h.data_path.join("main").join("t").join("innocent2.parquet");
let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
let deleted =
delete_orphaned_files_sqlite(&h.writer, store.clone(), CleanupCriteria::All, false)
.await
.unwrap();
assert_eq!(
deleted.len(),
2,
"All on empty catalog deletes every .parquet"
);
assert!(!innocent1.exists());
assert!(!innocent2.exists());
write_physical_file(&h.data_path, "main", "t", "fresh.parquet");
let fresh = h.data_path.join("main").join("t").join("fresh.parquet");
let cutoff = chrono::Utc::now() - chrono::Duration::hours(1);
let kept =
delete_orphaned_files_sqlite(&h.writer, store, CleanupCriteria::OlderThan(cutoff), false)
.await
.unwrap();
assert!(
kept.is_empty(),
"OlderThan filter saves a fresh unreferenced file"
);
assert!(
fresh.exists(),
"fresh file survives OlderThan sweep on empty catalog"
);
}
async fn visible_rows(pool: &SqlitePool, table_id: i64, snapshot: i64) -> i64 {
scalar_i64(
pool,
&format!(
"SELECT COALESCE(SUM(record_count), 0) FROM ducklake_data_file
WHERE table_id = {table_id}
AND {snapshot} >= begin_snapshot
AND ({snapshot} < end_snapshot OR end_snapshot IS NULL)"
),
)
.await
}
async fn head(pool: &SqlitePool) -> i64 {
scalar_i64(
pool,
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.await
}
#[tokio::test(flavor = "multi_thread")]
async fn replace_defers_head_and_retirement_until_register() {
let h = setup().await;
let p = pool(&h).await;
let s1 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s1.table_id,
s1.snapshot_id,
&DataFileInfo::new("gen1.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s1.column_ids,
)
.unwrap();
assert_eq!(head(&p).await, s1.snapshot_id, "gen 1 is the head");
assert_eq!(visible_rows(&p, s1.table_id, s1.snapshot_id).await, 5);
let s2 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
assert_eq!(s2.snapshot_id, s1.snapshot_id + 1, "reserved the next id");
assert_eq!(
head(&p).await,
s1.snapshot_id,
"head must stay at gen 1 until register commits the snapshot",
);
assert_eq!(
scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_data_file
WHERE table_id = {} AND end_snapshot IS NULL",
s1.table_id
),
)
.await,
1,
"gen 1 file must not be retired during the upload window",
);
assert_eq!(
visible_rows(&p, s1.table_id, s1.snapshot_id).await,
5,
"old generation still serves its complete data",
);
h.writer
.register_data_file(
s2.table_id,
s2.snapshot_id,
&DataFileInfo::new("gen2.parquet", 100, 7),
WriteMode::Replace,
&cols(),
&s2.column_ids,
)
.unwrap();
assert_eq!(head(&p).await, s2.snapshot_id, "head advanced to gen 2");
assert_eq!(
visible_rows(&p, s2.table_id, s2.snapshot_id).await,
7,
"gen 2 fully visible at the new head",
);
assert_eq!(
scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_data_file
WHERE table_id = {} AND end_snapshot = {}",
s1.table_id, s2.snapshot_id
),
)
.await,
1,
"gen 1 file retired exactly at the new snapshot",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn replace_does_not_leak_new_column_generation_until_register() {
let h = setup().await;
let p = pool(&h).await;
let live_cols = |table_id: i64| {
format!(
"SELECT COUNT(*) FROM ducklake_column \
WHERE table_id = {table_id} AND end_snapshot IS NULL"
)
};
let s1 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s1.table_id,
s1.snapshot_id,
&DataFileInfo::new("g1.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s1.column_ids,
)
.unwrap();
assert_eq!(scalar_i64(&p, &live_cols(s1.table_id)).await, 2);
let evolved = vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
ColumnDef::new("extra", "int64", true).unwrap(),
];
let s2 = h
.writer
.begin_write_transaction("main", "t", &evolved, WriteMode::Replace)
.unwrap();
assert_eq!(
scalar_i64(&p, &live_cols(s1.table_id)).await,
2,
"new column generation must not be visible until register commits",
);
h.writer
.register_data_file(
s2.table_id,
s2.snapshot_id,
&DataFileInfo::new("g2.parquet", 100, 7),
WriteMode::Replace,
&evolved,
&s2.column_ids,
)
.unwrap();
assert_eq!(
scalar_i64(&p, &live_cols(s2.table_id)).await,
3,
"new column generation visible after register",
);
let max_live_col = scalar_i64(
&p,
&format!(
"SELECT COALESCE(MAX(column_id), 0) FROM ducklake_column \
WHERE table_id = {} AND end_snapshot IS NULL",
s2.table_id
),
)
.await;
assert_eq!(
max_live_col,
*s2.column_ids.iter().max().unwrap(),
"committed column ids match the ids reserved at begin",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn replace_out_of_order_commit_does_not_corrupt() {
let h = setup().await;
let p = pool(&h).await;
let s0 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
s0.table_id,
s0.snapshot_id,
&DataFileInfo::new("gen0.parquet", 100, 5),
WriteMode::Replace,
&cols(),
&s0.column_ids,
)
.unwrap();
let tid = s0.table_id;
let w1 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
let w2 = h
.writer
.begin_write_transaction("main", "t", &cols(), WriteMode::Replace)
.unwrap();
h.writer
.register_data_file(
w2.table_id,
w2.snapshot_id,
&DataFileInfo::new("gen_w2.parquet", 100, 7),
WriteMode::Replace,
&cols(),
&w2.column_ids,
)
.unwrap();
h.writer
.register_data_file(
w1.table_id,
w1.snapshot_id,
&DataFileInfo::new("gen_w1.parquet", 100, 3),
WriteMode::Replace,
&cols(),
&w1.column_ids,
)
.unwrap();
let live_files = scalar_i64(&p, &format!("SELECT COUNT(*) FROM ducklake_data_file WHERE table_id = {tid} AND end_snapshot IS NULL")).await;
let live_cols = scalar_i64(
&p,
&format!(
"SELECT COUNT(*) FROM ducklake_column WHERE table_id = {tid} AND end_snapshot IS NULL"
),
)
.await;
let inverted = scalar_i64(&p, &format!("SELECT COUNT(*) FROM ducklake_column WHERE table_id = {tid} AND end_snapshot IS NOT NULL AND end_snapshot < begin_snapshot")).await;
assert_eq!(
inverted, 0,
"no column may end before it begins (published snapshot mutated)"
);
assert_eq!(
live_files, 1,
"exactly one file generation may be live at the head after Replace"
);
assert_eq!(
live_cols,
cols().len() as i64,
"exactly one column generation may be live at the head"
);
}