use std::{
fs::{File, OpenOptions},
path::PathBuf,
str::FromStr,
sync::{Arc, Mutex},
};
use crate::Result;
use crate::error::{TypeChangeOperation, TypeChangeWriteMode};
use crate::maintenance::{
CleanupCriteria, ExpireCriteria, ExpiredSnapshot, ScheduledFile, format_sql_timestamp,
};
use crate::metadata_provider::block_on;
use crate::metadata_writer::is_inlined_system_column;
use crate::metadata_writer::{
ColumnDef, ColumnStat, CommitIds, DataFileInfo, DeleteFileEntry, DeleteFileInfo,
ExistingCatalogColumn, INLINED_INDEX_COLUMNS_SETTING, InlinedRowRef, MetadataWriter,
MultiTableCommit, SnapshotCommitMetadata, StagedTableData, StagedTableWrite, WriteMode,
WriteSetupResult, assign_column_ids, catalog_column_defs, catalog_column_type_equal,
catalog_column_type_requires_migration, catalog_columns_differ, encode_inlined_index_columns,
inlined_delete_conflicts, inlined_delete_groups, live_inlined_index_columns,
parse_inlined_index_columns, snapshot_has_change, staged_table_write_changes,
table_storage_changes, table_write_changes, top_level_column_ids, validate_delete_entries,
validate_inlined_index_columns, validate_name, validate_table_setting,
};
use crate::partition::PartitionTransform;
use arrow::array::{
Array, BinaryArray, BinaryViewArray, BooleanArray, FixedSizeBinaryArray, Float32Array,
Float64Array, Int8Array, Int16Array, Int32Array, Int64Array, LargeBinaryArray,
LargeStringArray, StringArray, UInt8Array, UInt16Array, UInt32Array, UInt64Array,
};
use arrow::datatypes::DataType;
use arrow::record_batch::RecordBatch;
use sqlx::AssertSqlSafe;
use sqlx::QueryBuilder;
use sqlx::Row;
use sqlx::sqlite::{Sqlite, SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
const DEFAULT_MAX_CONNECTIONS: u32 = 5;
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn inlined_sqlite_type(data_type: &DataType) -> &'static str {
match data_type {
DataType::Boolean
| DataType::Int8
| DataType::Int16
| DataType::Int32
| DataType::Int64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32 => "BIGINT",
DataType::UInt64 => "TEXT",
DataType::Float32 | DataType::Float64 => "DOUBLE",
DataType::Binary | DataType::LargeBinary | DataType::BinaryView => "BLOB",
DataType::FixedSizeBinary(size) if *size != 16 => "BLOB",
_ => "VARCHAR",
}
}
fn sqlite_type_inlines(data_type: &DataType) -> bool {
crate::nested_inline::type_supports_inlining(data_type)
}
fn push_inlined_sqlite_value(
query: &mut QueryBuilder<Sqlite>,
array: &dyn Array,
row: usize,
) -> Result<()> {
if array.is_null(row) {
query.push_bind(Option::<String>::None);
return Ok(());
}
macro_rules! signed {
($array:ty) => {{
query.push_bind(
array
.as_any()
.downcast_ref::<$array>()
.expect("Arrow data type and array implementation agree")
.value(row) as i64,
);
}};
}
macro_rules! unsigned {
($array:ty) => {{
let value = array
.as_any()
.downcast_ref::<$array>()
.expect("Arrow data type and array implementation agree")
.value(row);
query.push_bind(i64::try_from(value).map_err(|_| {
crate::error::DuckLakeError::Unsupported(format!(
"SQLite inlined data cannot represent unsigned value {value}"
))
})?);
}};
}
match array.data_type() {
DataType::Boolean => {
query.push_bind(
array
.as_any()
.downcast_ref::<BooleanArray>()
.expect("Arrow data type and array implementation agree")
.value(row),
);
},
DataType::Int8 => signed!(Int8Array),
DataType::Int16 => signed!(Int16Array),
DataType::Int32 => signed!(Int32Array),
DataType::Int64 => signed!(Int64Array),
DataType::UInt8 => unsigned!(UInt8Array),
DataType::UInt16 => unsigned!(UInt16Array),
DataType::UInt32 => unsigned!(UInt32Array),
DataType::UInt64 => {
query.push_bind(format!(
"{}",
array
.as_any()
.downcast_ref::<UInt64Array>()
.expect("Arrow data type and array implementation agree")
.value(row),
));
},
DataType::Float32 => {
query.push_bind(f64::from(
array
.as_any()
.downcast_ref::<Float32Array>()
.expect("Arrow data type and array implementation agree")
.value(row),
));
},
DataType::Float64 => {
query.push_bind(
array
.as_any()
.downcast_ref::<Float64Array>()
.expect("Arrow data type and array implementation agree")
.value(row),
);
},
DataType::Utf8 => {
query.push_bind(
array
.as_any()
.downcast_ref::<StringArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_owned(),
);
},
DataType::LargeUtf8 => {
query.push_bind(
array
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_owned(),
);
},
DataType::Binary => {
query.push_bind(
array
.as_any()
.downcast_ref::<BinaryArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_vec(),
);
},
DataType::LargeBinary => {
query.push_bind(
array
.as_any()
.downcast_ref::<LargeBinaryArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_vec(),
);
},
DataType::BinaryView => {
query.push_bind(
array
.as_any()
.downcast_ref::<BinaryViewArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_vec(),
);
},
DataType::FixedSizeBinary(size) if *size != 16 => {
query.push_bind(
array
.as_any()
.downcast_ref::<FixedSizeBinaryArray>()
.expect("Arrow data type and array implementation agree")
.value(row)
.to_vec(),
);
},
_ => {
query.push_bind(crate::metadata_writer::inlined_text_value(array, row)?);
},
};
Ok(())
}
async fn sqlite_inlined_index_columns(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
) -> Result<Vec<String>> {
let value: Option<String> = sqlx::query_scalar(
"SELECT value FROM ducklake_metadata
WHERE key = ? AND scope = 'table' AND scope_id = ?",
)
.bind(INLINED_INDEX_COLUMNS_SETTING)
.bind(table_id)
.fetch_optional(&mut **tx)
.await?;
let declared = parse_inlined_index_columns(value.as_deref().unwrap_or_default())?;
let available = sqlx::query_scalar::<_, String>(
"SELECT column_name FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL AND parent_column IS NULL",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?
.into_iter()
.collect::<std::collections::HashSet<_>>();
Ok(live_inlined_index_columns(declared, &available))
}
async fn ensure_sqlite_physical_indexes(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
physical_name: &str,
) -> Result<()> {
let declared = sqlite_inlined_index_columns(tx, table_id).await?;
let present = sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info(?)")
.bind(physical_name)
.fetch_all(&mut **tx)
.await?
.into_iter()
.collect::<std::collections::HashSet<_>>();
for column in declared.iter().map(String::as_str) {
if !present.contains(column) {
continue;
}
let index_name = format!("{physical_name}_{column}_idx");
let exists: bool = sqlx::query_scalar(
"SELECT COUNT(*) > 0 FROM sqlite_master
WHERE type = 'index' AND name = ? AND tbl_name = ?",
)
.bind(&index_name)
.bind(physical_name)
.fetch_one(&mut **tx)
.await?;
if exists {
continue;
}
sqlx::query(AssertSqlSafe(format!(
"CREATE INDEX IF NOT EXISTS {} ON {}({})",
quote_ident(&index_name),
quote_ident(physical_name),
quote_ident(column),
)))
.execute(&mut **tx)
.await?;
}
Ok(())
}
fn id_list(ids: &[i64]) -> String {
ids.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")
}
const SQL_CREATE_SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS ducklake_metadata (
key VARCHAR NOT NULL,
value VARCHAR NOT NULL,
scope VARCHAR,
scope_id INTEGER
);
-- `schema_version` is the per-catalog monotonic schema counter from the DuckLake
-- spec (`ducklake_metadata_manager.cpp:232`): it bumps on a schema change (DDL)
-- and is carried forward unchanged on a data write, exactly mirroring upstream's
-- `if (SchemaChangesMade()) schema_version++` (`ducklake_transaction_state.cpp:1826`)
-- and the validated Postgres writer here. Upstream also stores `next_catalog_id` /
-- `next_file_id` on this row as ITS id allocators; we deliberately omit them (as the
-- Postgres writer does) because this library allocates ids from its own counters
-- (the `next_column_id` metadata row + autoincrement PKs), never from the snapshot.
CREATE TABLE IF NOT EXISTS ducklake_snapshot (
snapshot_id INTEGER PRIMARY KEY,
snapshot_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
schema_version INTEGER NOT NULL DEFAULT 0
);
-- Per-table schema-change ledger (DuckLake spec, `ducklake_metadata_manager.cpp:281`):
-- one row per (table that changed schema, snapshot at which it changed). Written on
-- every DDL that leaves the table live (create, column add/remove/reorder, type
-- promotion); NOT written for a drop (the table has no live schema afterward). Rows
-- are never tombstoned (no `end_snapshot`); they go unreferenced once the table is
-- fully expired and are reclaimed by vacuum — same as upstream `DropTables`.
CREATE TABLE IF NOT EXISTS ducklake_schema_versions (
begin_snapshot INTEGER NOT NULL,
schema_version INTEGER NOT NULL,
table_id INTEGER NOT NULL,
UNIQUE (table_id, begin_snapshot)
);
CREATE TABLE IF NOT EXISTS ducklake_schema (
schema_id INTEGER PRIMARY KEY,
schema_name VARCHAR NOT NULL,
path VARCHAR NOT NULL DEFAULT '',
path_is_relative BOOLEAN NOT NULL DEFAULT 1,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
);
CREATE TABLE IF NOT EXISTS ducklake_table (
table_id INTEGER PRIMARY KEY,
schema_id INTEGER NOT NULL,
table_name VARCHAR NOT NULL,
path VARCHAR NOT NULL DEFAULT '',
path_is_relative BOOLEAN NOT NULL DEFAULT 1,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
);
-- Faithful match of upstream `duckdb/ducklake` `ducklake_column`
-- (`ducklake_metadata_manager.cpp:258`): a BARE table — no PRIMARY KEY, no
-- NOT NULL, no DEFAULT — in upstream's exact column order. `column_id` is
-- deliberately NOT a single-row PK: a column is versioned by
-- `[begin_snapshot, end_snapshot)` and a stable-id type promotion writes a
-- SECOND row with the same `column_id`, which a single-row PK would forbid.
-- One-live-row / non-null / ownership invariants are enforced in the writer
-- code + tests, exactly as upstream guarantees them (its table has no such
-- constraints). The four `*default*` columns + `parent_column` are projected by
-- DuckDB when it reads catalogs we produce; we leave them NULL (no nested-type
-- or column-default writes yet). See docs/column-id-versioning-design.md §4.1.
CREATE TABLE IF NOT EXISTS ducklake_view (
view_id BIGINT,
view_uuid UUID,
begin_snapshot BIGINT,
end_snapshot BIGINT,
schema_id BIGINT,
view_name VARCHAR,
dialect VARCHAR,
sql VARCHAR,
column_aliases VARCHAR
);
CREATE TABLE IF NOT EXISTS ducklake_column (
column_id BIGINT,
begin_snapshot BIGINT,
end_snapshot BIGINT,
table_id BIGINT,
column_order BIGINT,
column_name VARCHAR,
column_type VARCHAR,
initial_default VARCHAR,
default_value VARCHAR,
nulls_allowed BOOLEAN,
parent_column BIGINT,
default_value_type VARCHAR,
default_value_dialect VARCHAR
);
-- `partial_max` (DuckLake v1.0) marks a *partial data file* produced by
-- `merge_adjacent_files`: it is the maximum origin snapshot id among the file's
-- merged rows, whose per-row origin is stored in the embedded
-- `_ducklake_internal_snapshot_id` parquet column. NULL for ordinary files
-- (single-origin appends, and `rewrite_data_files` outputs).
CREATE TABLE IF NOT EXISTS ducklake_data_file (
data_file_id INTEGER PRIMARY KEY,
table_id INTEGER NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT 1,
file_size_bytes INTEGER NOT NULL,
footer_size INTEGER,
encryption_key VARCHAR,
record_count INTEGER,
row_id_start INTEGER,
mapping_id INTEGER,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER,
partial_max INTEGER,
-- References ducklake_partition_info.partition_id: the partition spec this
-- file was written under. NULL for files of an unpartitioned table.
partition_id INTEGER
);
-- Per-snapshot change ledger (DuckLake spec). One row per snapshot recording
-- what the commit did, as a comma-separated `changes_made` string.
CREATE TABLE IF NOT EXISTS ducklake_snapshot_changes (
snapshot_id INTEGER PRIMARY KEY,
changes_made VARCHAR,
author VARCHAR,
commit_message VARCHAR,
commit_extra_info VARCHAR
);
-- Per-table row-lineage counter (DuckLake spec). `next_row_id` is the
-- monotonic rowid allocator: a new data file gets its `row_id_start` from
-- the current value, then we advance by `record_count` in the same
-- transaction. `record_count` and `file_size_bytes` mirror the currently-
-- visible totals so DuckDB's `ducklake_table_info` aggregate sees correct
-- numbers for tables we wrote.
CREATE TABLE IF NOT EXISTS ducklake_table_stats (
table_id INTEGER PRIMARY KEY,
record_count INTEGER NOT NULL DEFAULT 0,
next_row_id INTEGER NOT NULL DEFAULT 0,
file_size_bytes INTEGER NOT NULL DEFAULT 0
);
-- Per-file, per-column statistics (DuckLake spec zone maps). Powers file-level
-- pruning: min/max are the DuckDB-canonical VARCHAR encoding of the bounds,
-- value_count is the non-null count, null_count the null count. Column set
-- mirrors the official extension exactly (no PK, matching upstream). A brand-
-- new table for pre-existing catalogs: `CREATE TABLE IF NOT EXISTS` (re-run by
-- initialize_schema on every open) creates it, which is also what lets DuckDB
-- attach a catalog we wrote (its global-stats query joins the two stats tables).
CREATE TABLE IF NOT EXISTS ducklake_file_column_stats (
data_file_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
column_id INTEGER NOT NULL,
column_size_bytes INTEGER,
value_count INTEGER,
null_count INTEGER,
min_value VARCHAR,
max_value VARCHAR,
contains_nan BOOLEAN,
extra_stats VARCHAR
);
-- Table-wide per-column roll-up (DuckLake spec). Feeds the query optimizer
-- (cardinality/column bounds), not file pruning. `contains_null` is a boolean
-- (any null across the table), not a count; min/max widen across all files.
CREATE TABLE IF NOT EXISTS ducklake_table_column_stats (
table_id INTEGER NOT NULL,
column_id INTEGER NOT NULL,
contains_null BOOLEAN,
contains_nan BOOLEAN,
min_value VARCHAR,
max_value VARCHAR,
extra_stats VARCHAR
);
CREATE TABLE IF NOT EXISTS ducklake_delete_file (
delete_file_id INTEGER PRIMARY KEY,
data_file_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT 1,
file_size_bytes INTEGER NOT NULL,
footer_size INTEGER,
encryption_key VARCHAR,
delete_count INTEGER,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER,
-- Max embedded per-row snapshot of a cumulative delete file (current
-- DuckLake spec). This crate's writer emits per-snapshot delete files and
-- leaves it NULL; readers use it to window cumulative files by row.
partial_max INTEGER
);
-- Files queued for physical deletion by the two-phase vacuum (DuckLake spec).
-- `expire_snapshots` GCs unreachable catalog rows and records the orphaned
-- physical paths here; `cleanup_old_files` deletes the objects and removes
-- these rows. `path` is stored relative to the catalog `data_path` root
-- (i.e. already resolved through schema/table) so cleanup needs only a
-- single-level join with `data_path`. Mirrors the upstream
-- `ducklake_files_scheduled_for_deletion` table.
CREATE TABLE IF NOT EXISTS ducklake_files_scheduled_for_deletion (
data_file_id INTEGER NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT 1,
schedule_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Partition spec generations (DuckLake spec). One row per version of a table's
-- partition spec; `end_snapshot` NULL means currently active. Setting or
-- resetting the spec ends the live row and (for SET) starts a new one, exactly
-- like column versioning. `partition_id` is a catalog-wide id (this crate
-- allocates it from the `next_partition_id` metadata counter).
CREATE TABLE IF NOT EXISTS ducklake_partition_info (
partition_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
);
-- Partition-key columns for a partition spec (DuckLake spec). One row per key,
-- ordered by `partition_key_index` (0-based). `transform` is the transform
-- string (`identity`, `year`, `month`, `day`, `hour`, `bucket(N)`).
CREATE TABLE IF NOT EXISTS ducklake_partition_column (
partition_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
partition_key_index INTEGER NOT NULL,
column_id INTEGER NOT NULL,
transform VARCHAR NOT NULL
);
-- Per-file partition values (DuckLake spec). The single transformed value that
-- every row in `data_file_id` shares for partition key `partition_key_index`,
-- encoded as a DuckDB-canonical VARCHAR (NULL is a legal partition value).
CREATE TABLE IF NOT EXISTS ducklake_file_partition_value (
data_file_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
partition_key_index INTEGER NOT NULL,
partition_value VARCHAR
);
-- Sort spec (DuckLake spec). Snapshot-versioned like the partition spec:
-- `end_snapshot` NULL means currently active; SET/RESET ends the live row and
-- (for SET) starts a new one. Unlike partitioning, a sort-spec change does NOT
-- bump `schema_version`. `sort_id` is allocated from the `next_sort_id` counter.
CREATE TABLE IF NOT EXISTS ducklake_sort_info (
sort_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
);
-- Sort-key expressions for a sort spec (DuckLake spec). One row per key, ordered
-- by `sort_key_index` (0-based). `expression` is a sort expression in `dialect`
-- (this crate produces bare column names under the `duckdb` dialect);
-- `sort_direction` is `ASC`/`DESC`; `null_order` is `NULLS_FIRST`/`NULLS_LAST`.
CREATE TABLE IF NOT EXISTS ducklake_sort_expression (
sort_id INTEGER NOT NULL,
table_id INTEGER NOT NULL,
sort_key_index INTEGER NOT NULL,
expression VARCHAR NOT NULL,
dialect VARCHAR NOT NULL,
sort_direction VARCHAR NOT NULL,
null_order VARCHAR NOT NULL
);
"#;
const SQL_CREATE_INLINED_DATA_TABLES: &str =
"CREATE TABLE IF NOT EXISTS ducklake_inlined_data_tables (
table_id INTEGER,
table_name VARCHAR,
schema_version INTEGER
);";
#[derive(Debug, Clone)]
pub struct SqliteMetadataWriter {
pool: SqlitePool,
lock_path: Option<PathBuf>,
commit_lock: Arc<Mutex<()>>,
}
impl SqliteMetadataWriter {
pub async fn new(connection_string: &str) -> Result<Self> {
Self::with_max_connections(connection_string, DEFAULT_MAX_CONNECTIONS).await
}
pub async fn with_max_connections(
connection_string: &str,
max_connections: u32,
) -> Result<Self> {
let options = SqliteConnectOptions::from_str(connection_string)?;
let filename = options.get_filename();
let lock_path = (!filename.as_os_str().is_empty()
&& filename != std::path::Path::new(":memory:"))
.then(|| {
let mut path = filename.as_os_str().to_os_string();
path.push(".commit.lock");
PathBuf::from(path)
});
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(connection_string)
.await?;
migrate_snapshot_changes_nullable(&pool).await?;
sqlx::query(SQL_CREATE_INLINED_DATA_TABLES)
.execute(&pool)
.await?;
Ok(Self {
pool,
lock_path,
commit_lock: Arc::new(Mutex::new(())),
})
}
pub async fn new_with_init(connection_string: &str) -> Result<Self> {
let writer = Self::new(connection_string).await?;
writer.initialize_schema()?;
Ok(writer)
}
pub fn drop_table(&self, schema_name: &str, table_name: &str) -> Result<bool> {
validate_name(schema_name, "Schema")?;
validate_name(table_name, "Table")?;
block_on(async {
let mut tx = self.pool.begin().await?;
let (drop_snapshot, _schema_version) = insert_snapshot(&mut tx).await?;
let table_id: i64 = match sqlx::query(
"SELECT t.table_id FROM ducklake_table t
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE s.schema_name = ? AND s.end_snapshot IS NULL
AND t.table_name = ? AND t.end_snapshot IS NULL",
)
.bind(schema_name)
.bind(table_name)
.fetch_optional(&mut *tx)
.await?
{
Some(r) => r.try_get(0)?,
None => return Ok(false),
};
bump_schema_version(&mut tx, drop_snapshot).await?;
for child in
["ducklake_table", "ducklake_column", "ducklake_data_file", "ducklake_delete_file"]
{
sqlx::query(AssertSqlSafe(format!(
"UPDATE {child} SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL"
)))
.bind(drop_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?;
}
record_snapshot_changes(
&mut tx,
drop_snapshot,
&format!("dropped_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(true)
})
}
pub fn expire_snapshots(&self, criteria: ExpireCriteria) -> Result<Vec<ExpiredSnapshot>> {
block_on(async {
let mut tx = self.pool.begin().await?;
let most_recent: Option<i64> =
sqlx::query("SELECT MAX(snapshot_id) FROM ducklake_snapshot")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let most_recent = match most_recent {
Some(id) => id,
None => {
tx.commit().await?;
return Ok(Vec::new());
},
};
let candidates: Vec<ExpiredSnapshot> = match &criteria {
ExpireCriteria::Versions(versions) => {
let ids: Vec<i64> = versions
.iter()
.copied()
.filter(|&v| v != most_recent)
.collect();
if ids.is_empty() {
tx.commit().await?;
return Ok(Vec::new());
}
let rows = sqlx::query(AssertSqlSafe(format!(
"SELECT snapshot_id, snapshot_time FROM ducklake_snapshot
WHERE snapshot_id IN ({}) ORDER BY snapshot_id",
id_list(&ids)
)))
.fetch_all(&mut *tx)
.await?;
rows_to_snapshots(rows)?
},
ExpireCriteria::OlderThan(ts) => {
let rows = sqlx::query(
"SELECT snapshot_id, snapshot_time FROM ducklake_snapshot
WHERE snapshot_id != ? AND snapshot_time < ? ORDER BY snapshot_id",
)
.bind(most_recent)
.bind(format_sql_timestamp(ts))
.fetch_all(&mut *tx)
.await?;
rows_to_snapshots(rows)?
},
};
if candidates.is_empty() {
tx.commit().await?;
return Ok(Vec::new());
}
let expire_ids: Vec<i64> = candidates.iter().map(|s| s.snapshot_id).collect();
for table in ["ducklake_snapshot", "ducklake_snapshot_changes"] {
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM {table} WHERE snapshot_id IN ({})",
id_list(&expire_ids)
)))
.execute(&mut *tx)
.await?;
}
let dead_tables: Vec<i64> = sqlx::query(
"SELECT t.table_id FROM ducklake_table t
WHERE t.end_snapshot IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ducklake_snapshot
WHERE snapshot_id >= t.begin_snapshot AND snapshot_id < t.end_snapshot)
AND NOT EXISTS (
SELECT 1 FROM ducklake_table t2
WHERE t2.table_id = t.table_id
AND (t2.end_snapshot IS NULL OR EXISTS (
SELECT 1 FROM ducklake_snapshot
WHERE snapshot_id >= t2.begin_snapshot
AND snapshot_id < t2.end_snapshot)))",
)
.fetch_all(&mut *tx)
.await?
.into_iter()
.map(|r| r.try_get::<i64, _>(0))
.collect::<std::result::Result<_, _>>()?;
let dead_table_filter = if dead_tables.is_empty() {
"0".to_string()
} else {
format!("df.table_id IN ({})", id_list(&dead_tables))
};
let dead_data_files = sqlx::query(AssertSqlSafe(format!(
"SELECT df.data_file_id, {RESOLVED_PATH} AS resolved_path, {REL_FLAG} AS rel
FROM ducklake_data_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE ({dead_table_filter}) OR (df.end_snapshot IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ducklake_snapshot
WHERE snapshot_id >= df.begin_snapshot AND snapshot_id < df.end_snapshot))"
)))
.fetch_all(&mut *tx)
.await?;
let data_file_ids = schedule_files(&mut tx, dead_data_files).await?;
if !data_file_ids.is_empty() {
for table in [
"ducklake_data_file",
"ducklake_file_column_stats",
"ducklake_file_partition_value",
] {
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM {table} WHERE data_file_id IN ({})",
id_list(&data_file_ids)
)))
.execute(&mut *tx)
.await?;
}
}
let dead_data_filter = if data_file_ids.is_empty() {
"0".to_string()
} else {
format!("df.data_file_id IN ({})", id_list(&data_file_ids))
};
let dead_delete_table_filter = if dead_tables.is_empty() {
"0".to_string()
} else {
format!("df.table_id IN ({})", id_list(&dead_tables))
};
let dead_delete_files = sqlx::query(AssertSqlSafe(format!(
"SELECT df.delete_file_id, {RESOLVED_PATH} AS resolved_path, {REL_FLAG} AS rel
FROM ducklake_delete_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE ({dead_data_filter}) OR ({dead_delete_table_filter})
OR (df.end_snapshot IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ducklake_snapshot
WHERE snapshot_id >= df.begin_snapshot AND snapshot_id < df.end_snapshot))"
)))
.fetch_all(&mut *tx)
.await?;
let delete_file_ids = schedule_files(&mut tx, dead_delete_files).await?;
if !delete_file_ids.is_empty() {
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_delete_file WHERE delete_file_id IN ({})",
id_list(&delete_file_ids)
)))
.execute(&mut *tx)
.await?;
}
if !dead_tables.is_empty() {
let dead = id_list(&dead_tables);
for table in [
"ducklake_table",
"ducklake_table_stats",
"ducklake_table_column_stats",
"ducklake_partition_info",
"ducklake_partition_column",
"ducklake_column",
"ducklake_sort_info",
"ducklake_sort_expression",
"ducklake_schema_versions",
] {
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM {table} WHERE table_id IN ({dead})"
)))
.execute(&mut *tx)
.await?;
}
}
sqlx::query(
"DELETE FROM ducklake_schema
WHERE end_snapshot IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ducklake_snapshot
WHERE snapshot_id >= ducklake_schema.begin_snapshot
AND snapshot_id < ducklake_schema.end_snapshot)",
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(candidates)
})
}
pub(crate) fn list_scheduled_for_deletion(
&self,
criteria: &CleanupCriteria,
) -> Result<Vec<ScheduledFile>> {
block_on(async {
let rows = match criteria {
CleanupCriteria::All => {
sqlx::query(
"SELECT data_file_id, path, path_is_relative
FROM ducklake_files_scheduled_for_deletion",
)
.fetch_all(&self.pool)
.await?
},
CleanupCriteria::OlderThan(ts) => {
sqlx::query(
"SELECT data_file_id, path, path_is_relative
FROM ducklake_files_scheduled_for_deletion
WHERE schedule_start < ?",
)
.bind(format_sql_timestamp(ts))
.fetch_all(&self.pool)
.await?
},
};
rows.into_iter()
.map(|r| {
Ok(ScheduledFile {
data_file_id: r.try_get(0)?,
path: r.try_get(1)?,
path_is_relative: r.try_get::<i64, _>(2)? != 0,
scheduled_by_owner: None,
})
})
.collect()
})
}
pub(crate) fn remove_scheduled(&self, ids: &[i64]) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
block_on(async {
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_files_scheduled_for_deletion WHERE data_file_id IN ({})",
id_list(ids)
)))
.execute(&self.pool)
.await?;
Ok(())
})
}
pub(crate) fn list_referenced_paths(&self) -> Result<Vec<(String, bool)>> {
block_on(async {
let q = format!(
"SELECT {RESOLVED_PATH} AS p, {REL_FLAG} AS rel
FROM ducklake_data_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
UNION ALL
SELECT {RESOLVED_PATH} AS p, {REL_FLAG} AS rel
FROM ducklake_delete_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
UNION ALL
SELECT path AS p, CAST(path_is_relative AS INTEGER) AS rel
FROM ducklake_files_scheduled_for_deletion"
);
let rows = sqlx::query(AssertSqlSafe(q.as_str()))
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|r| {
let p: String = r.try_get(0)?;
let rel: i64 = r.try_get(1)?;
Ok((p, rel != 0))
})
.collect()
})
}
}
const RESOLVED_PATH: &str = "CASE
WHEN NOT df.path_is_relative THEN df.path
WHEN NOT t.path_is_relative THEN t.path || '/' || df.path
ELSE s.path || '/' || t.path || '/' || df.path
END";
const REL_FLAG: &str =
"(CASE WHEN df.path_is_relative AND t.path_is_relative AND s.path_is_relative
THEN 1 ELSE 0 END)";
fn rows_to_snapshots(rows: Vec<sqlx::sqlite::SqliteRow>) -> Result<Vec<ExpiredSnapshot>> {
rows.into_iter()
.map(|r| {
Ok(ExpiredSnapshot {
snapshot_id: r.try_get(0)?,
snapshot_time: r.try_get(1)?,
})
})
.collect()
}
async fn schedule_files(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
rows: Vec<sqlx::sqlite::SqliteRow>,
) -> Result<Vec<i64>> {
let mut ids = Vec::with_capacity(rows.len());
for row in rows {
let id: i64 = row.try_get(0)?;
let path: String = row.try_get(1)?;
let rel: i64 = row.try_get(2)?;
sqlx::query(
"INSERT INTO ducklake_files_scheduled_for_deletion
(data_file_id, path, path_is_relative, schedule_start)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
)
.bind(id)
.bind(&path)
.bind(rel)
.execute(&mut **tx)
.await?;
ids.push(id);
}
Ok(ids)
}
async fn reserve_ids(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
key: &str,
n: i64,
) -> Result<i64> {
let last: i64 = sqlx::query(
"UPDATE ducklake_metadata
SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)
WHERE key = ? AND scope IS NULL
RETURNING CAST(value AS INTEGER)",
)
.bind(n)
.bind(key)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
Ok(last)
}
async fn migrate_ducklake_column_drop_pk(pool: &SqlitePool) -> Result<()> {
let info = sqlx::query("SELECT name, pk FROM pragma_table_info('ducklake_column')")
.fetch_all(pool)
.await?;
let mut legacy_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut column_id_is_pk = false;
for row in &info {
let name: String = row.try_get("name")?;
let pk: i64 = row.try_get("pk")?;
if name == "column_id" && pk != 0 {
column_id_is_pk = true;
}
legacy_cols.insert(name);
}
let has_default_columns =
["initial_default", "default_value", "default_value_type", "default_value_dialect"]
.iter()
.all(|name| legacy_cols.contains(*name));
if !column_id_is_pk && has_default_columns {
return Ok(());
}
const TARGET: &[&str] = &[
"column_id",
"begin_snapshot",
"end_snapshot",
"table_id",
"column_order",
"column_name",
"column_type",
"initial_default",
"default_value",
"nulls_allowed",
"parent_column",
"default_value_type",
"default_value_dialect",
];
let select_list = TARGET
.iter()
.map(|c| {
if legacy_cols.contains(*c) {
(*c).to_string()
} else {
"NULL".to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
let insert_list = TARGET.join(", ");
let mut tx = pool.begin().await?;
sqlx::query(
"CREATE TABLE ducklake_column__migrate (
column_id BIGINT,
begin_snapshot BIGINT,
end_snapshot BIGINT,
table_id BIGINT,
column_order BIGINT,
column_name VARCHAR,
column_type VARCHAR,
initial_default VARCHAR,
default_value VARCHAR,
nulls_allowed BOOLEAN,
parent_column BIGINT,
default_value_type VARCHAR,
default_value_dialect VARCHAR
)",
)
.execute(&mut *tx)
.await?;
sqlx::query(AssertSqlSafe(format!(
"INSERT INTO ducklake_column__migrate ({insert_list}) \
SELECT {select_list} FROM ducklake_column"
)))
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE ducklake_column")
.execute(&mut *tx)
.await?;
sqlx::query("ALTER TABLE ducklake_column__migrate RENAME TO ducklake_column")
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn migrate_add_schema_version(pool: &SqlitePool) -> Result<()> {
let info = sqlx::query("SELECT name FROM pragma_table_info('ducklake_snapshot')")
.fetch_all(pool)
.await?;
let mut has_schema_version = false;
for row in &info {
let name: String = row.try_get("name")?;
if name == "schema_version" {
has_schema_version = true;
}
}
if !has_schema_version {
sqlx::query(
"ALTER TABLE ducklake_snapshot ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0",
)
.execute(pool)
.await?;
}
Ok(())
}
async fn migrate_add_metadata_scope_columns(pool: &SqlitePool) -> Result<()> {
let columns = sqlx::query("SELECT name FROM pragma_table_info('ducklake_metadata')")
.fetch_all(pool)
.await?;
let has_scope = columns
.iter()
.any(|row| row.get::<String, _>("name") == "scope");
let has_scope_id = columns
.iter()
.any(|row| row.get::<String, _>("name") == "scope_id");
if !has_scope {
sqlx::query("ALTER TABLE ducklake_metadata ADD COLUMN scope VARCHAR")
.execute(pool)
.await?;
}
if !has_scope_id {
sqlx::query("ALTER TABLE ducklake_metadata ADD COLUMN scope_id INTEGER")
.execute(pool)
.await?;
}
Ok(())
}
async fn migrate_add_partial_max(pool: &SqlitePool) -> Result<()> {
let info = sqlx::query("SELECT name FROM pragma_table_info('ducklake_data_file')")
.fetch_all(pool)
.await?;
let mut has_partial_max = false;
for row in &info {
let name: String = row.try_get("name")?;
if name == "partial_max" {
has_partial_max = true;
}
}
if !has_partial_max {
sqlx::query("ALTER TABLE ducklake_data_file ADD COLUMN partial_max INTEGER")
.execute(pool)
.await?;
}
Ok(())
}
async fn migrate_add_partition_id(pool: &SqlitePool) -> Result<()> {
let info = sqlx::query("SELECT name FROM pragma_table_info('ducklake_data_file')")
.fetch_all(pool)
.await?;
let mut has_partition_id = false;
for row in &info {
let name: String = row.try_get("name")?;
if name == "partition_id" {
has_partition_id = true;
}
}
if !has_partition_id {
sqlx::query("ALTER TABLE ducklake_data_file ADD COLUMN partition_id INTEGER")
.execute(pool)
.await?;
}
Ok(())
}
async fn migrate_snapshot_changes_nullable(pool: &SqlitePool) -> Result<()> {
let info = sqlx::query("SELECT name FROM pragma_table_info('ducklake_snapshot_changes')")
.fetch_all(pool)
.await?;
if info.is_empty() {
return Ok(());
}
let columns = info
.iter()
.map(|row| row.try_get::<String, _>("name"))
.collect::<std::result::Result<Vec<_>, _>>()?;
for (name, statement) in [
(
"author",
"ALTER TABLE ducklake_snapshot_changes ADD COLUMN author VARCHAR DEFAULT NULL",
),
(
"commit_message",
"ALTER TABLE ducklake_snapshot_changes ADD COLUMN commit_message VARCHAR DEFAULT NULL",
),
(
"commit_extra_info",
"ALTER TABLE ducklake_snapshot_changes ADD COLUMN commit_extra_info VARCHAR DEFAULT NULL",
),
] {
if !columns.iter().any(|column| column == name) {
sqlx::query(statement).execute(pool).await?;
}
}
let not_null: i64 = sqlx::query_scalar(
"SELECT `notnull` FROM pragma_table_info('ducklake_snapshot_changes')
WHERE name = 'changes_made'",
)
.fetch_one(pool)
.await?;
if not_null == 0 {
return Ok(());
}
let mut tx = pool.begin().await?;
sqlx::query(
"CREATE TABLE ducklake_snapshot_changes__migrate (
snapshot_id INTEGER PRIMARY KEY,
changes_made VARCHAR,
author VARCHAR,
commit_message VARCHAR,
commit_extra_info VARCHAR
)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_snapshot_changes__migrate
(snapshot_id, changes_made, author, commit_message, commit_extra_info)
SELECT snapshot_id, NULLIF(changes_made, ''), author, commit_message, commit_extra_info
FROM ducklake_snapshot_changes",
)
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE ducklake_snapshot_changes")
.execute(&mut *tx)
.await?;
sqlx::query(
"ALTER TABLE ducklake_snapshot_changes__migrate RENAME TO ducklake_snapshot_changes",
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn detect_replace_conflict(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
base_snapshot: i64,
) -> Result<()> {
let conflict: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?)
LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut **tx)
.await?;
if conflict.is_some() {
return Err(crate::DuckLakeError::Conflict(format!(
"Replace on table {table_id} conflicts with a concurrent write committed since \
snapshot {base_snapshot}; aborting (retry the write against the new generation)"
)));
}
let inlined_tables =
sqlx::query("SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = ?")
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
for row in inlined_tables {
let table_name: String = row.try_get(0)?;
let sql = format!(
"SELECT 1 FROM {} WHERE begin_snapshot > ? OR end_snapshot > ? LIMIT 1",
quote_ident(&table_name)
);
let conflict: Option<i64> = sqlx::query_scalar(AssertSqlSafe(sql))
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut **tx)
.await?;
if conflict.is_some() {
return Err(crate::DuckLakeError::Conflict(format!(
"Replace on table {table_id} conflicts with inlined data committed since \
snapshot {base_snapshot}; aborting"
)));
}
}
Ok(())
}
async fn detect_new_inlined_deletes(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
base_snapshot: i64,
data_file_ids: &[i64],
) -> Result<()> {
if data_file_ids.is_empty() {
return Ok(());
}
let table = crate::metadata_provider::inlined_delete_table_name(table_id)?;
let exists: Option<i64> =
sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(&table)
.fetch_optional(&mut **tx)
.await?;
if exists.is_none() {
return Ok(());
}
let conflict: Option<i64> = sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT 1 FROM \"{table}\" newer
WHERE newer.file_id IN ({})
AND (newer.begin_snapshot IS NULL OR (
newer.begin_snapshot > ?
AND (newer.row_id IS NULL OR NOT EXISTS (
SELECT 1 FROM \"{table}\" prior
WHERE prior.file_id = newer.file_id
AND prior.row_id = newer.row_id
AND prior.begin_snapshot <= ?
))
))
LIMIT 1",
id_list(data_file_ids),
)))
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut **tx)
.await?;
if conflict.is_some() {
return Err(crate::DuckLakeError::Conflict(format!(
"table {table_id} gained an inlined delete on a source file since snapshot \
{base_snapshot}; re-open the catalog and re-plan"
)));
}
Ok(())
}
async fn retire_prior_generation(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
snapshot_id: i64,
) -> Result<()> {
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL AND begin_snapshot < ?",
)
.bind(snapshot_id)
.bind(table_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
let inlined_tables =
sqlx::query("SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = ?")
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
for row in inlined_tables {
let table_name: String = row.try_get(0)?;
let sql = format!(
"UPDATE {} SET end_snapshot = ? \
WHERE end_snapshot IS NULL AND begin_snapshot < ?",
quote_ident(&table_name)
);
sqlx::query(AssertSqlSafe(sql))
.bind(snapshot_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
}
sqlx::query(
"UPDATE ducklake_table_stats SET record_count = 0, file_size_bytes = 0 WHERE table_id = ?",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn replaced_storage(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
snapshot_id: i64,
) -> Result<(bool, bool)> {
let replaced: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot = ?
)",
)
.bind(table_id)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
for table_name in inlined_table_names(tx, table_id).await? {
let sql = format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE end_snapshot = ?)",
quote_ident(&table_name)
);
if sqlx::query_scalar(AssertSqlSafe(sql))
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?
{
return Ok((replaced, true));
}
}
Ok((replaced, false))
}
async fn inlined_table_names(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
) -> Result<Vec<String>> {
let rows =
sqlx::query("SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = ?")
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
rows.into_iter().map(|row| Ok(row.try_get(0)?)).collect()
}
async fn live_inlined_row_count(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
) -> Result<i64> {
let mut total = 0;
for table_name in inlined_table_names(tx, table_id).await? {
let sql = format!(
"SELECT COUNT(*) FROM {} WHERE end_snapshot IS NULL",
quote_ident(&table_name)
);
total += sqlx::query_scalar::<_, i64>(AssertSqlSafe(sql))
.fetch_one(&mut **tx)
.await?;
}
Ok(total)
}
async fn apply_positional_deletes(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
snapshot_id: i64,
base_snapshot: i64,
deletes: &[DeleteFileEntry],
) -> Result<()> {
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut **tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: the file is no longer live since \
snapshot {base_snapshot}",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut **tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: its live delete file changed from \
{:?} to {current_prev:?} since snapshot {base_snapshot}",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(previous) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE delete_file_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(previous)
.execute(&mut **tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn apply_inlined_deletes(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
snapshot_id: i64,
base_snapshot: i64,
rows: &[InlinedRowRef],
) -> Result<()> {
if rows.is_empty() {
return Ok(());
}
let registered: Vec<String> = sqlx::query_scalar(
"SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = ?",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
let changes: Vec<Option<String>> = sqlx::query_scalar("SELECT changes_made FROM ducklake_snapshot_changes WHERE snapshot_id > ? AND snapshot_id < ?").bind(base_snapshot).bind(snapshot_id).fetch_all(&mut **tx).await?;
if changes
.iter()
.flatten()
.any(|changes| inlined_delete_conflicts(changes, table_id))
{
return Err(crate::DuckLakeError::Conflict(
"table changed since the inlined delete snapshot".to_string(),
));
}
for name in ®istered {
let sql = format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE (begin_snapshot > ? AND begin_snapshot < ?) OR (end_snapshot > ? AND end_snapshot < ?))",
quote_ident(name)
);
let changed: bool = sqlx::query_scalar(AssertSqlSafe(sql))
.bind(base_snapshot)
.bind(snapshot_id)
.bind(base_snapshot)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
if changed {
return Err(crate::DuckLakeError::Conflict(
"inlined rows changed since the delete snapshot".to_string(),
));
}
}
for (name, row_ids) in inlined_delete_groups(rows) {
if !registered.iter().any(|table| table == name) {
return Err(crate::DuckLakeError::Conflict(format!(
"inlined deletes reference unregistered table '{name}'"
)));
}
let ids = row_ids
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"UPDATE {} SET end_snapshot = ? WHERE row_id IN ({ids}) AND end_snapshot IS NULL AND begin_snapshot <> ?",
quote_ident(name)
);
sqlx::query(AssertSqlSafe(sql))
.bind(snapshot_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn insert_snapshot(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result<(i64, i64)> {
let row = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_id, snapshot_time, schema_version)
SELECT COALESCE(MAX(snapshot_id), 0) + 1, CURRENT_TIMESTAMP,
COALESCE(MAX(schema_version), 0)
FROM ducklake_snapshot
RETURNING snapshot_id, schema_version",
)
.fetch_one(&mut **tx)
.await?;
let snapshot_id = row.try_get(0)?;
let schema_version = row.try_get(1)?;
sqlx::query(
"INSERT INTO ducklake_snapshot_changes (snapshot_id, changes_made)
VALUES (?, NULL)",
)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok((snapshot_id, schema_version))
}
async fn bump_schema_version(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
) -> Result<i64> {
let prev_max: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(schema_version), 0) FROM ducklake_snapshot WHERE snapshot_id <> ?",
)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
let new_version = prev_max + 1;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = ? WHERE snapshot_id = ?")
.bind(new_version)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok(new_version)
}
async fn record_schema_version(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
schema_version: i64,
table_id: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO ducklake_schema_versions (begin_snapshot, schema_version, table_id)
VALUES (?, ?, ?)",
)
.bind(snapshot_id)
.bind(schema_version)
.bind(table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_partition_metadata(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
data_file_id: i64,
file: &DataFileInfo,
) -> Result<()> {
if let Some(partition_id) = file.partition_id {
sqlx::query("UPDATE ducklake_data_file SET partition_id = ? WHERE data_file_id = ?")
.bind(partition_id)
.bind(data_file_id)
.execute(&mut **tx)
.await?;
}
for (key_index, value) in &file.partition_values {
sqlx::query(
"INSERT INTO ducklake_file_partition_value
(data_file_id, table_id, partition_key_index, partition_value)
VALUES (?, ?, ?, ?)",
)
.bind(data_file_id)
.bind(table_id)
.bind(i64::from(*key_index))
.bind(value.clone())
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn insert_file_column_stats(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
data_file_id: i64,
column_stats: &[ColumnStat],
) -> Result<()> {
for stat in column_stats {
sqlx::query(
"INSERT INTO ducklake_file_column_stats
(data_file_id, table_id, column_id, column_size_bytes,
value_count, null_count, min_value, max_value, contains_nan, extra_stats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)",
)
.bind(data_file_id)
.bind(table_id)
.bind(stat.column_id)
.bind(stat.column_size_bytes)
.bind(stat.value_count)
.bind(stat.null_count)
.bind(stat.min_value.as_deref())
.bind(stat.max_value.as_deref())
.bind(stat.contains_nan)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn live_columns_for_stats(
table_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(Vec<ColumnDef>, Vec<i64>)> {
let mut columns = Vec::new();
let mut column_ids = Vec::new();
for row in sqlx::query(
"SELECT column_id, column_name, column_type
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL AND parent_column IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?
{
let column_id: i64 = row.try_get(0)?;
let name: String = row.try_get(1)?;
let ducklake_type: String = row.try_get(2)?;
column_ids.push(column_id);
columns.push(ColumnDef {
name,
data_type: crate::types::ducklake_to_arrow_type(&ducklake_type)
.unwrap_or(arrow::datatypes::DataType::Null),
ducklake_type,
is_nullable: true,
initial_default: None,
default_value: None,
default_value_type: None,
default_value_dialect: None,
});
}
Ok((columns, column_ids))
}
async fn recompute_table_column_stats(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<()> {
use crate::stats_encode::{FileColumnStat, aggregate_global_column_stats};
let catalog_columns = catalog_column_defs(columns)?;
let column_ids = top_level_column_ids(&catalog_columns, column_ids)?;
let live_file_count: i64 = sqlx::query(
"SELECT COUNT(*) FROM ducklake_data_file WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
let mut per_file: Vec<FileColumnStat> = Vec::new();
for row in sqlx::query(
"SELECT s.column_id, s.min_value, s.max_value, s.null_count, s.contains_nan
FROM ducklake_file_column_stats s
JOIN ducklake_data_file d ON d.data_file_id = s.data_file_id
WHERE d.table_id = ? AND d.end_snapshot IS NULL",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?
{
per_file.push(FileColumnStat {
column_id: row.try_get(0)?,
min_value: row.try_get(1)?,
max_value: row.try_get(2)?,
null_count: row.try_get(3)?,
contains_nan: row.try_get(4)?,
});
}
let numeric_of = |column_id: i64| -> bool {
column_ids
.iter()
.position(|id| *id == column_id)
.and_then(|i| columns.get(i))
.map(|c| crate::stats_encode::is_numeric_ducklake_type(c.ducklake_type()))
.unwrap_or(false)
};
let globals = aggregate_global_column_stats(&per_file, live_file_count, numeric_of);
sqlx::query("DELETE FROM ducklake_table_column_stats WHERE table_id = ?")
.bind(table_id)
.execute(&mut **tx)
.await?;
for g in globals {
sqlx::query(
"INSERT INTO ducklake_table_column_stats
(table_id, column_id, contains_null, contains_nan, min_value, max_value, extra_stats)
VALUES (?, ?, ?, ?, ?, ?, NULL)",
)
.bind(table_id)
.bind(g.column_id)
.bind(g.contains_null)
.bind(g.contains_nan)
.bind(g.min_value)
.bind(g.max_value)
.execute(&mut **tx)
.await?;
}
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "snapshot finalization requires the complete atomic write state"
)]
async fn finalize_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
column_ids: &[i64],
mode: WriteMode,
base_snapshot: i64,
) -> Result<i64> {
let (snapshot_id, mut schema_version) = insert_snapshot(tx).await?;
let mut schema_changed = false;
finalize_table_snapshot(
tx,
snapshot_id,
&mut schema_version,
&mut schema_changed,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
Ok(snapshot_id)
}
#[expect(
clippy::too_many_arguments,
reason = "table finalization requires the complete atomic write state"
)]
async fn finalize_table_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
schema_version: &mut i64,
schema_changed: &mut bool,
table_id: i64,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
column_ids: &[i64],
mode: WriteMode,
base_snapshot: i64,
) -> Result<()> {
let (schema_begin_snapshot, table_begin_snapshot): (i64, i64) = sqlx::query_as(
"SELECT schema.begin_snapshot, table_meta.begin_snapshot
FROM ducklake_table table_meta
JOIN ducklake_schema schema ON schema.schema_id = table_meta.schema_id
WHERE table_meta.table_id = ?",
)
.bind(table_id)
.fetch_one(&mut **tx)
.await?;
use std::collections::{HashMap, HashSet};
let proposed = catalog_column_defs(columns)?;
if proposed.len() != column_ids.len() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"column_ids has {} entries for {} catalog column nodes",
column_ids.len(),
proposed.len()
)));
}
let current = sqlx::query(
"SELECT column_id, column_name, column_type, column_order, nulls_allowed, parent_column
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
let existing_catalog_columns = current
.iter()
.map(|row| {
Ok::<_, sqlx::Error>(ExistingCatalogColumn {
column_id: row.try_get("column_id")?,
name: row.try_get("column_name")?,
ducklake_type: row.try_get("column_type")?,
parent_column: row.try_get("parent_column")?,
})
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let existing_nullability = current
.iter()
.map(|row| {
Ok::<_, sqlx::Error>(
row.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true),
)
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let committed_ids = assign_column_ids(&proposed, &existing_catalog_columns, column_ids)?;
if committed_ids != column_ids {
return Err(crate::DuckLakeError::Conflict(
"table columns were created concurrently with different field ids; retry the write"
.to_string(),
));
}
let table_was_created = table_begin_snapshot == snapshot_id;
let is_ddl = table_was_created
|| catalog_columns_differ(
&existing_catalog_columns,
&existing_nullability,
&proposed,
column_ids,
);
if is_ddl && !*schema_changed {
*schema_version = bump_schema_version(tx, snapshot_id).await?;
*schema_changed = true;
}
let proposed_ids = column_ids.iter().copied().collect::<HashSet<_>>();
let mut current_by_id: HashMap<i64, (i64, bool, String)> = HashMap::new();
for row in ¤t {
let column_id: i64 = row.try_get("column_id")?;
let order: i64 = row.try_get("column_order")?;
let nullable: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
let ducklake_type: String = row.try_get("column_type")?;
if !proposed_ids.contains(&column_id) {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
}
current_by_id.insert(column_id, (order, nullable, ducklake_type));
}
for (order, (column, column_id)) in proposed.iter().zip(column_ids).enumerate() {
let parent_id = column.parent_index.map(|index| column_ids[index]);
match current_by_id.get(column_id) {
Some((cur_order, cur_nullable, cur_type)) => {
let migrate_type = catalog_column_type_requires_migration(cur_type, column);
if migrate_type {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
} else if *cur_order != order as i64 || *cur_nullable != column.is_nullable {
sqlx::query(
"UPDATE ducklake_column
SET column_order = ?, nulls_allowed = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(order as i64)
.bind(column.is_nullable)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
}
},
None => {
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot, initial_default,
default_value, default_value_type, default_value_dialect)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.bind(snapshot_id)
.bind(&column.initial_default)
.bind(&column.default_value)
.bind(&column.default_value_type)
.bind(&column.default_value_dialect)
.execute(&mut **tx)
.await?;
},
}
}
if mode == WriteMode::Replace {
detect_replace_conflict(tx, table_id, base_snapshot).await?;
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
retire_prior_generation(tx, table_id, snapshot_id).await?;
}
if is_ddl {
record_schema_version(tx, snapshot_id, *schema_version, table_id).await?;
}
let mut ddl_changes = Vec::new();
if table_was_created {
if schema_begin_snapshot == table_begin_snapshot {
let schema_entry = format!(
"created_schema:{}",
crate::metadata_writer::quote_snapshot_name(schema_name),
);
let recorded: Option<Option<String>> = sqlx::query_scalar(
"SELECT changes_made FROM ducklake_snapshot_changes WHERE snapshot_id = ?",
)
.bind(snapshot_id)
.fetch_optional(&mut **tx)
.await?;
let duplicate = matches!(
recorded,
Some(Some(ref changes)) if snapshot_has_change(changes, &schema_entry)
);
if !duplicate {
ddl_changes.push(schema_entry);
}
}
ddl_changes.push(format!(
"created_table:{}",
crate::metadata_writer::quote_snapshot_table(schema_name, table_name),
));
} else if is_ddl {
ddl_changes.push(format!("altered_table:{table_id}"));
}
if !ddl_changes.is_empty() {
record_snapshot_changes(
tx,
snapshot_id,
&ddl_changes.join(","),
&SnapshotCommitMetadata::default(),
)
.await?;
}
Ok(())
}
async fn record_snapshot_changes(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
changes_made: &str,
commit_metadata: &SnapshotCommitMetadata,
) -> Result<()> {
let changes_made = (!changes_made.is_empty()).then_some(changes_made);
sqlx::query(
"UPDATE ducklake_snapshot_changes
SET changes_made = CASE
WHEN changes_made IS NULL THEN ?
WHEN ? IS NULL THEN changes_made
ELSE changes_made || ',' || ?
END,
author = ?, commit_message = ?, commit_extra_info = ?
WHERE snapshot_id = ?",
)
.bind(changes_made)
.bind(changes_made)
.bind(changes_made)
.bind(commit_metadata.author())
.bind(commit_metadata.message())
.bind(commit_metadata.extra_info())
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn validate_staged_table(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
write: &StagedTableWrite,
) -> Result<()> {
let ended: Option<Option<i64>> =
sqlx::query_scalar("SELECT end_snapshot FROM ducklake_table WHERE table_id = ?")
.bind(write.table_id)
.fetch_optional(&mut **tx)
.await?;
if ended.is_none() || matches!(ended, Some(Some(_))) {
return Err(crate::DuckLakeError::Conflict(
"staged table was dropped".to_string(),
));
}
let current = sqlx::query("SELECT column_id, column_type, nulls_allowed, parent_column FROM ducklake_column WHERE table_id = ? AND end_snapshot IS NULL ORDER BY column_order").bind(write.table_id).fetch_all(&mut **tx).await?;
let proposed = catalog_column_defs(&write.columns)?;
if current.is_empty() && proposed.len() == write.column_ids.len() {
return Ok(());
}
let mut matches = current.len() == proposed.len() && proposed.len() == write.column_ids.len();
if matches {
for (index, (row, column)) in current.iter().zip(&proposed).enumerate() {
let column_id: i64 = row.try_get("column_id")?;
let column_type: String = row.try_get("column_type")?;
let nullable: Option<bool> = row.try_get("nulls_allowed")?;
let parent: Option<i64> = row.try_get("parent_column")?;
matches &= column_id == write.column_ids[index]
&& catalog_column_type_equal(&column_type, column)
&& nullable.unwrap_or(true) == column.is_nullable
&& parent == column.parent_index.map(|i| write.column_ids[i]);
}
}
if !matches {
return Err(crate::DuckLakeError::Conflict(
"table schema changed after staging".to_string(),
));
}
Ok(())
}
async fn commit_files_at_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
write: &StagedTableWrite,
files: &[DataFileInfo],
) -> Result<bool> {
if files.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"multi-table file stage must contain at least one file".to_string(),
));
}
let live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(write.table_id)
.fetch_optional(&mut **tx)
.await?;
for file in files {
crate::metadata_writer::enforce_partition_fence(write.table_id, live_partition_id, file)?;
}
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(write.table_id)
.execute(&mut **tx)
.await?;
let mut next_row_id: i64 =
sqlx::query_scalar("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(write.table_id)
.fetch_one(&mut **tx)
.await?;
let mut total_advance = 0;
let mut total_records = 0;
let mut total_bytes = 0;
for file in files {
let row_ids = crate::metadata_writer::allocate_row_id_start(next_row_id, file);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes, footer_size,
record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(write.table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_ids.stored)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
let data_file_id: i64 = sqlx::query_scalar("SELECT last_insert_rowid()")
.fetch_one(&mut **tx)
.await?;
insert_file_column_stats(tx, write.table_id, data_file_id, &file.column_stats).await?;
insert_partition_metadata(tx, write.table_id, data_file_id, file).await?;
next_row_id += row_ids.advance;
total_advance += row_ids.advance;
total_records += file.record_count;
total_bytes += file.file_size_bytes;
}
recompute_table_column_stats(tx, write.table_id, &write.columns, &write.column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?, record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(total_advance)
.bind(total_records)
.bind(total_bytes)
.bind(write.table_id)
.execute(&mut **tx)
.await?;
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot = ?
)",
)
.bind(write.table_id)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?)
}
async fn commit_inlined_at_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
snapshot_id: i64,
write: &StagedTableWrite,
batches: &[RecordBatch],
) -> Result<()> {
enforce_inline_partition_fence(tx, write.table_id, write.base_snapshot_id).await?;
let record_count: usize = batches.iter().map(RecordBatch::num_rows).sum();
if record_count == 0 {
return Err(crate::DuckLakeError::InvalidConfig(
"multi-table inline stage must contain at least one row".to_string(),
));
}
let schema_version: i64 =
sqlx::query_scalar("SELECT schema_version FROM ducklake_snapshot WHERE snapshot_id = ?")
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
let physical_name = format!(
"ducklake_inlined_data_{}_{}",
write.table_id, schema_version
);
let mut ddl = format!(
"CREATE TABLE IF NOT EXISTS {} (\
row_id BIGINT, begin_snapshot BIGINT, end_snapshot BIGINT",
quote_ident(&physical_name)
);
for (field, column) in batches[0].schema().fields().iter().zip(&write.columns) {
ddl.push_str(", ");
ddl.push_str("e_ident(column.name()));
ddl.push(' ');
ddl.push_str(inlined_sqlite_type(field.data_type()));
}
ddl.push(')');
sqlx::query(AssertSqlSafe(ddl)).execute(&mut **tx).await?;
ensure_sqlite_physical_indexes(tx, write.table_id, &physical_name).await?;
sqlx::query(
"INSERT INTO ducklake_inlined_data_tables (table_id, table_name, schema_version)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_inlined_data_tables
WHERE table_id = ? AND schema_version = ?
)",
)
.bind(write.table_id)
.bind(&physical_name)
.bind(schema_version)
.bind(write.table_id)
.bind(schema_version)
.execute(&mut **tx)
.await?;
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(write.table_id)
.execute(&mut **tx)
.await?;
let mut row_id: i64 =
sqlx::query_scalar("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(write.table_id)
.fetch_one(&mut **tx)
.await?;
let column_list = write
.columns
.iter()
.map(|column| quote_ident(column.name()))
.collect::<Vec<_>>()
.join(", ");
for batch in batches {
for batch_row in 0..batch.num_rows() {
let mut query = QueryBuilder::<Sqlite>::new(format!(
"INSERT INTO {} (row_id, begin_snapshot, end_snapshot, {}) VALUES (",
quote_ident(&physical_name),
column_list
));
query.push_bind(row_id);
query.push(", ").push_bind(snapshot_id);
query.push(", NULL");
for (array, column) in batch.columns().iter().zip(&write.columns) {
query.push(", ");
if write
.snapshot_id_columns
.iter()
.any(|name| name == column.name())
&& array.is_null(batch_row)
{
query.push_bind(snapshot_id);
} else {
push_inlined_sqlite_value(&mut query, array.as_ref(), batch_row)?;
}
}
query.push(')');
query.build().execute(&mut **tx).await?;
row_id += 1;
}
}
let record_count = i64::try_from(record_count).map_err(|_| {
crate::DuckLakeError::InvalidConfig("multi-table inline row count exceeds i64".to_string())
})?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?, record_count = record_count + ?
WHERE table_id = ?",
)
.bind(record_count)
.bind(record_count)
.bind(write.table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
impl MetadataWriter for SqliteMetadataWriter {
fn supports_update(&self) -> bool {
true
}
fn set_table_setting(&self, table_id: i64, key: &str, value: &str) -> Result<()> {
let key = validate_table_setting(key)?;
block_on(async {
let mut transaction = self.pool.begin().await?;
let table_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM ducklake_table WHERE table_id = ? AND end_snapshot IS NULL)",
)
.bind(table_id)
.fetch_one(&mut *transaction)
.await?;
if !table_exists {
return Err(crate::DuckLakeError::TableNotFound(table_id.to_string()));
}
sqlx::query(
"DELETE FROM ducklake_metadata
WHERE key = ? AND scope = 'table' AND scope_id = ?",
)
.bind(&key)
.bind(table_id)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope, scope_id)
VALUES (?, ?, 'table', ?)",
)
.bind(&key)
.bind(value)
.bind(table_id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
})
}
fn set_inlined_index_columns(&self, table_id: i64, columns: &[String]) -> Result<()> {
let value = encode_inlined_index_columns(columns)?;
block_on(async {
let mut transaction = self.pool.begin().await?;
let available = sqlx::query_scalar::<_, String>(
"SELECT column_name FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL AND parent_column IS NULL",
)
.bind(table_id)
.fetch_all(&mut *transaction)
.await?
.into_iter()
.collect::<std::collections::HashSet<_>>();
if available.is_empty() {
return Err(crate::DuckLakeError::TableNotFound(table_id.to_string()));
}
validate_inlined_index_columns(columns, &available)?;
let existing: Option<String> = sqlx::query_scalar(
"SELECT value FROM ducklake_metadata
WHERE key = ? AND scope = 'table' AND scope_id = ?",
)
.bind(INLINED_INDEX_COLUMNS_SETTING)
.bind(table_id)
.fetch_optional(&mut *transaction)
.await?;
if existing.as_deref() == Some(value.as_str()) {
transaction.commit().await?;
return Ok(());
}
sqlx::query(
"DELETE FROM ducklake_metadata
WHERE key = ? AND scope = 'table' AND scope_id = ?",
)
.bind(INLINED_INDEX_COLUMNS_SETTING)
.bind(table_id)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope, scope_id)
VALUES (?, ?, 'table', ?)",
)
.bind(INLINED_INDEX_COLUMNS_SETTING)
.bind(value)
.bind(table_id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
})
}
fn ensure_inlined_indexes(&self, table_id: i64) -> Result<()> {
block_on(async {
let mut transaction = self.pool.begin().await?;
let physical_tables = sqlx::query_scalar::<_, String>(
"SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = ?",
)
.bind(table_id)
.fetch_all(&mut *transaction)
.await?;
sqlite_inlined_index_columns(&mut transaction, table_id).await?;
for physical_table in physical_tables {
ensure_sqlite_physical_indexes(&mut transaction, table_id, &physical_table).await?;
}
transaction.commit().await?;
Ok(())
})
}
fn with_commit_lock(
&self,
_identity: &str,
operation: Box<dyn FnOnce() -> Result<()> + '_>,
) -> Result<()> {
if let Some(lock_path) = &self.lock_path {
let lock_file = OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(lock_path)?;
File::lock(&lock_file)?;
let result = operation();
let unlock = File::unlock(&lock_file);
return match (result, unlock) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(e)) => Err(e.into()),
(Err(e), _) => Err(e),
};
}
let _guard = self.commit_lock.lock().map_err(|_| {
crate::DuckLakeError::Internal("SQLite commit lock is poisoned".to_string())
})?;
operation()
}
fn create_snapshot(&self) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
tx.commit().await?;
Ok(snapshot_id)
})
}
fn set_global_setting(&self, key: &str, value: &str) -> Result<()> {
block_on(async {
let mut transaction = self.pool.begin().await?;
sqlx::query("DELETE FROM ducklake_metadata WHERE key = ? AND scope IS NULL")
.bind(key)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope, scope_id) VALUES (?, ?, NULL, NULL)",
)
.bind(key)
.bind(value)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
})
}
fn get_or_create_schema(
&self,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)> {
validate_name(name, "Schema")?;
block_on(async {
let mut tx = self.pool.begin().await?;
let existing = sqlx::query(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
let schema_id = row.try_get(0)?;
tx.commit().await?;
return Ok((schema_id, false));
}
let schema_path = path.unwrap_or(name);
let row = sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, 1, ?) RETURNING schema_id",
)
.bind(name)
.bind(schema_path)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
let schema_id = row.try_get(0)?;
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!(
"created_schema:{}",
crate::metadata_writer::quote_snapshot_name(name),
),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok((schema_id, true))
})
}
fn get_or_create_table(
&self,
schema_id: i64,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)> {
validate_name(name, "Table")?;
block_on(async {
let mut tx = self.pool.begin().await?;
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
let table_id = row.try_get(0)?;
tx.commit().await?;
return Ok((table_id, false));
}
let schema_name: String =
sqlx::query_scalar("SELECT schema_name FROM ducklake_schema WHERE schema_id = ?")
.bind(schema_id)
.fetch_one(&mut *tx)
.await?;
let table_path = path.unwrap_or(name);
let row = sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, 1, ?) RETURNING table_id",
)
.bind(schema_id)
.bind(name)
.bind(table_path)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
let table_id = row.try_get(0)?;
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!(
"created_table:{}",
crate::metadata_writer::quote_snapshot_table(&schema_name, name),
),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok((table_id, true))
})
}
fn promote_column_type(
&self,
table_id: i64,
column_name: &str,
new_ducklake_type: &str,
) -> Result<i64> {
crate::types::ducklake_to_arrow_type(new_ducklake_type)?;
block_on(async {
let mut tx = self.pool.begin().await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let row = sqlx::query(
"SELECT column_id, column_type, column_order, nulls_allowed, parent_column,
initial_default, default_value, default_value_type, default_value_dialect
FROM ducklake_column
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL
AND parent_column IS NULL",
)
.bind(table_id)
.bind(column_name)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"promote_column_type: no live column '{column_name}' in table {table_id}"
))
})?;
let column_id: i64 = row.try_get("column_id")?;
let cur_type: String = row.try_get("column_type")?;
let column_order: i64 = row.try_get("column_order")?;
let nulls_allowed: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
let parent_column: Option<i64> = row.try_get("parent_column")?;
let initial_default: Option<String> = row.try_get("initial_default")?;
let default_value: Option<String> = row.try_get("default_value")?;
let default_value_type: Option<String> = row.try_get("default_value_type")?;
let default_value_dialect: Option<String> = row.try_get("default_value_dialect")?;
if crate::types::types_equal_canonical(&cur_type, new_ducklake_type) {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"promote_column_type: column '{column_name}' is already type '{cur_type}' (no change)"
)));
}
if !crate::types::is_promotable(&cur_type, new_ducklake_type) {
return Err(crate::DuckLakeError::UnsupportedTypeChange {
operation: TypeChangeOperation::PromoteColumnType,
column: column_name.to_string(),
from: cur_type,
to: new_ducklake_type.to_string(),
});
}
let new_schema_version = bump_schema_version(&mut tx, new_snapshot).await?;
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.bind(column_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_column
(column_id, begin_snapshot, end_snapshot, table_id, column_order, column_name,
column_type, nulls_allowed, parent_column, initial_default, default_value,
default_value_type, default_value_dialect)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(new_snapshot)
.bind(table_id)
.bind(column_order)
.bind(column_name)
.bind(new_ducklake_type)
.bind(nulls_allowed)
.bind(parent_column)
.bind(initial_default)
.bind(default_value)
.bind(default_value_type)
.bind(default_value_dialect)
.execute(&mut *tx)
.await?;
record_schema_version(&mut tx, new_snapshot, new_schema_version, table_id).await?;
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn set_partition_spec(
&self,
table_id: i64,
columns: &[(String, PartitionTransform)],
) -> Result<i64> {
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"set_partition_spec: partition spec must have at least one column; \
use reset_partition_spec to remove partitioning"
.to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let partition_id = reserve_ids(&mut tx, "next_partition_id", 1).await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let mut column_ids: Vec<i64> = Vec::with_capacity(columns.len());
for (name, _transform) in columns {
let column_id: i64 = sqlx::query_scalar(
"SELECT column_id FROM ducklake_column
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL
AND parent_column IS NULL",
)
.bind(table_id)
.bind(name)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"set_partition_spec: no live column '{name}' in table {table_id}"
))
})?;
column_ids.push(column_id);
}
sqlx::query(
"UPDATE ducklake_partition_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_partition_info
(partition_id, table_id, begin_snapshot, end_snapshot)
VALUES (?, ?, ?, NULL)",
)
.bind(partition_id)
.bind(table_id)
.bind(new_snapshot)
.execute(&mut *tx)
.await?;
for (key_index, column_id) in column_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO ducklake_partition_column
(partition_id, table_id, partition_key_index, column_id, transform)
VALUES (?, ?, ?, ?, ?)",
)
.bind(partition_id)
.bind(table_id)
.bind(key_index as i64)
.bind(*column_id)
.bind(columns[key_index].1.to_catalog_string())
.execute(&mut *tx)
.await?;
}
let new_schema_version = bump_schema_version(&mut tx, new_snapshot).await?;
record_schema_version(&mut tx, new_snapshot, new_schema_version, table_id).await?;
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn live_partition_spec(
&self,
table_id: i64,
) -> Result<Option<crate::partition::PartitionSpec>> {
block_on(async {
let rows = sqlx::query(
"SELECT pi.partition_id, pc.partition_key_index, pc.column_id, pc.transform
FROM ducklake_partition_info AS pi
JOIN ducklake_partition_column AS pc
ON pc.partition_id = pi.partition_id AND pc.table_id = pi.table_id
WHERE pi.table_id = ? AND pi.end_snapshot IS NULL
ORDER BY pc.partition_key_index",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let parsed = rows
.iter()
.map(|row| {
Ok::<_, crate::DuckLakeError>((
row.try_get::<i64, _>(0)?,
i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0),
row.try_get::<i64, _>(2)?,
row.try_get::<String, _>(3)?,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(crate::partition::PartitionSpec::from_rows(parsed, false))
})
}
fn reset_partition_spec(&self, table_id: i64) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let ended = sqlx::query(
"UPDATE ducklake_partition_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?
.rows_affected();
if ended == 0 {
drop(tx);
let head: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.fetch_one(&self.pool)
.await?;
return Ok(head);
}
let new_schema_version = bump_schema_version(&mut tx, new_snapshot).await?;
record_schema_version(&mut tx, new_snapshot, new_schema_version, table_id).await?;
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn live_sort_spec(&self, table_id: i64) -> Result<Option<crate::sort::SortSpec>> {
block_on(async {
let rows = sqlx::query(
"SELECT si.sort_id, se.sort_key_index, se.expression, se.dialect,
se.sort_direction, se.null_order
FROM ducklake_sort_info AS si
JOIN ducklake_sort_expression AS se
ON se.sort_id = si.sort_id AND se.table_id = si.table_id
WHERE si.table_id = ? AND si.end_snapshot IS NULL
ORDER BY se.sort_key_index",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let parsed = rows
.iter()
.map(|row| {
Ok::<_, crate::DuckLakeError>((
row.try_get::<i64, _>(0)?,
i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0),
row.try_get::<String, _>(2)?,
row.try_get::<String, _>(3)?,
row.try_get::<String, _>(4)?,
row.try_get::<String, _>(5)?,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(crate::sort::SortSpec::from_rows(parsed))
})
}
fn set_sort_spec(&self, table_id: i64, fields: &[crate::sort::SortField]) -> Result<i64> {
if fields.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"set_sort_spec: at least one sort key is required (use reset_sort_spec to clear)"
.to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let sort_id = reserve_ids(&mut tx, "next_sort_id", 1).await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
for field in fields {
let column = field.column_candidate().ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"set_sort_spec: sort key '{}' is not a bare column; only column \
sort keys are supported",
field.expression
))
})?;
let exists: Option<i64> = sqlx::query_scalar(
"SELECT column_id FROM ducklake_column
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL
AND parent_column IS NULL",
)
.bind(table_id)
.bind(&column)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"set_sort_spec: no live column '{column}' in table {table_id}"
)));
}
}
sqlx::query(
"UPDATE ducklake_sort_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_sort_info
(sort_id, table_id, begin_snapshot, end_snapshot)
VALUES (?, ?, ?, NULL)",
)
.bind(sort_id)
.bind(table_id)
.bind(new_snapshot)
.execute(&mut *tx)
.await?;
for field in fields {
sqlx::query(
"INSERT INTO ducklake_sort_expression
(sort_id, table_id, sort_key_index, expression, dialect,
sort_direction, null_order)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(sort_id)
.bind(table_id)
.bind(field.sort_key_index as i64)
.bind(&field.expression)
.bind(&field.dialect)
.bind(field.direction.to_catalog_string())
.bind(field.null_order.to_catalog_string())
.execute(&mut *tx)
.await?;
}
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn reset_sort_spec(&self, table_id: i64) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let ended = sqlx::query(
"UPDATE ducklake_sort_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?
.rows_affected();
if ended == 0 {
drop(tx);
let head: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.fetch_one(&self.pool)
.await?;
return Ok(head);
}
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn set_columns(
&self,
table_id: i64,
columns: &[ColumnDef],
snapshot_id: i64,
) -> Result<Vec<i64>> {
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"Table must have at least one column".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let table_begin_snapshot: i64 =
sqlx::query_scalar("SELECT begin_snapshot FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let catalog_columns = catalog_column_defs(columns)?;
let n = catalog_columns.len() as i64;
let last_column_id = reserve_ids(&mut tx, "next_column_id", n).await?;
let first_column_id = last_column_id - n + 1;
let column_ids = (first_column_id..=last_column_id).collect::<Vec<_>>();
for (order, (column, column_id)) in
catalog_columns.iter().zip(column_ids.iter()).enumerate()
{
let parent_id = column.parent_index.map(|index| column_ids[index]);
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot, initial_default,
default_value, default_value_type, default_value_dialect)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.bind(snapshot_id)
.bind(&column.initial_default)
.bind(&column.default_value)
.bind(&column.default_value_type)
.bind(&column.default_value_dialect)
.execute(&mut *tx)
.await?;
}
if table_begin_snapshot != snapshot_id {
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
}
tx.commit().await?;
top_level_column_ids(&catalog_columns, &column_ids)
})
}
fn register_data_file(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
file: &DataFileInfo,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
self.register_data_file_with_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
file,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
fn register_data_file_with_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
file: &DataFileInfo,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
if mode != WriteMode::Replace
&& let Some(expected_base_snapshot_id) = expected_base_snapshot_id
{
detect_replace_conflict(&mut tx, table_id, expected_base_snapshot_id).await?;
}
let live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let stats_row =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let next_row_id: i64 = stats_row.try_get(0)?;
let row_ids = crate::metadata_writer::allocate_row_id_start(next_row_id, file);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_ids.stored)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let data_file_id: i64 = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &file.column_stats).await?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).await?;
recompute_table_column_stats(&mut tx, table_id, columns, column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(row_ids.advance)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
let replaced_existing_data = replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made = table_write_changes(table_id, mode, false, replaced_existing_data);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata).await?;
let schema_id: i64 =
sqlx::query("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn register_data_files(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
self.register_data_files_with_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
files,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if files.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"register_data_files: files must be non-empty".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
if mode != WriteMode::Replace
&& let Some(expected_base_snapshot_id) = expected_base_snapshot_id
{
detect_replace_conflict(&mut tx, table_id, expected_base_snapshot_id).await?;
}
let live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
for file in files {
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
}
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let mut next_row_id: i64 =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let mut total_advance: i64 = 0;
let mut total_records: i64 = 0;
let mut total_bytes: i64 = 0;
for file in files {
let row_ids = crate::metadata_writer::allocate_row_id_start(next_row_id, file);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_ids.stored)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let data_file_id: i64 = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &file.column_stats)
.await?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).await?;
next_row_id += row_ids.advance;
total_advance += row_ids.advance;
total_records += file.record_count;
total_bytes += file.file_size_bytes;
}
recompute_table_column_stats(&mut tx, table_id, columns, column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(total_advance)
.bind(total_records)
.bind(total_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
let replaced_existing_data = replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made = table_write_changes(table_id, mode, false, replaced_existing_data);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata).await?;
let schema_id: i64 =
sqlx::query("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn supports_data_inlining(&self, schema: &arrow::datatypes::Schema) -> bool {
if schema
.fields()
.iter()
.any(|field| is_inlined_system_column(field.name()))
{
return false;
}
schema
.fields()
.iter()
.all(|field| sqlite_type_inlines(field.data_type()))
}
#[allow(clippy::too_many_arguments)]
fn register_inlined_data(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
batches: &[RecordBatch],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
block_on(async {
let record_count: usize = batches.iter().map(RecordBatch::num_rows).sum();
if record_count == 0 {
return Err(crate::DuckLakeError::InvalidConfig(
"register_inlined_data: batches must contain at least one row".to_string(),
));
}
if batches
.iter()
.any(|batch| batch.num_columns() != columns.len())
{
return Err(crate::DuckLakeError::InvalidConfig(
"register_inlined_data: batch schema does not match table columns".to_string(),
));
}
let mut tx = self.pool.begin().await?;
enforce_inline_partition_fence(&mut tx, table_id, base_snapshot).await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
if mode != WriteMode::Replace
&& let Some(expected_base_snapshot_id) = expected_base_snapshot_id
{
detect_replace_conflict(&mut tx, table_id, expected_base_snapshot_id).await?;
}
let schema_version: i64 = sqlx::query_scalar(
"SELECT schema_version FROM ducklake_snapshot WHERE snapshot_id = ?",
)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
let physical_name = format!("ducklake_inlined_data_{table_id}_{schema_version}");
let mut ddl = format!(
"CREATE TABLE IF NOT EXISTS {} (\
row_id BIGINT, begin_snapshot BIGINT, end_snapshot BIGINT",
quote_ident(&physical_name)
);
for (field, column) in batches[0].schema().fields().iter().zip(columns) {
ddl.push_str(", ");
ddl.push_str("e_ident(column.name()));
ddl.push(' ');
ddl.push_str(inlined_sqlite_type(field.data_type()));
}
ddl.push(')');
sqlx::query(AssertSqlSafe(ddl)).execute(&mut *tx).await?;
ensure_sqlite_physical_indexes(&mut tx, table_id, &physical_name).await?;
sqlx::query(
"INSERT INTO ducklake_inlined_data_tables
(table_id, table_name, schema_version)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_inlined_data_tables
WHERE table_id = ? AND schema_version = ?)",
)
.bind(table_id)
.bind(&physical_name)
.bind(schema_version)
.bind(table_id)
.bind(schema_version)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let mut row_id: i64 = sqlx::query_scalar(
"SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?",
)
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let column_list = columns
.iter()
.map(|column| quote_ident(column.name()))
.collect::<Vec<_>>()
.join(", ");
for batch in batches {
for batch_row in 0..batch.num_rows() {
let mut query = QueryBuilder::<Sqlite>::new(format!(
"INSERT INTO {} (row_id, begin_snapshot, end_snapshot, {}) VALUES (",
quote_ident(&physical_name),
column_list
));
query.push_bind(row_id);
query.push(", ").push_bind(snapshot_id);
query.push(", NULL");
for array in batch.columns() {
query.push(", ");
push_inlined_sqlite_value(&mut query, array.as_ref(), batch_row)?;
}
query.push(')');
query.build().execute(&mut *tx).await?;
row_id += 1;
}
}
let record_count = i64::try_from(record_count).map_err(|_| {
crate::DuckLakeError::InvalidConfig(
"register_inlined_data: record count exceeds i64".to_string(),
)
})?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?, record_count = record_count + ?
WHERE table_id = ?",
)
.bind(record_count)
.bind(record_count)
.bind(table_id)
.execute(&mut *tx)
.await?;
let (replaced_files, replaced_inlined) =
replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made = table_storage_changes(
table_id,
Some(true),
mode == WriteMode::Replace && replaced_files,
mode == WriteMode::Replace && replaced_inlined,
);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata).await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_multi_table(
&self,
writes: &[StagedTableWrite],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<MultiTableCommit> {
if writes.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_multi_table requires at least one table stage".to_string(),
));
}
let table_ids = writes
.iter()
.map(|write| write.table_id)
.collect::<std::collections::HashSet<_>>();
if table_ids.len() != writes.len() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_multi_table requires one stage per table".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
for write in writes {
validate_staged_table(&mut tx, write).await?;
}
if let Some(expected) = expected_base_snapshot_id {
for write in writes {
detect_replace_conflict(&mut tx, write.table_id, expected).await?;
}
}
let mut had_live_data = Vec::with_capacity(writes.len());
for write in writes {
let files: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL
)",
)
.bind(write.table_id)
.fetch_one(&mut *tx)
.await?;
had_live_data.push((
files,
live_inlined_row_count(&mut tx, write.table_id).await? > 0,
));
}
let (snapshot_id, mut schema_version) = insert_snapshot(&mut tx).await?;
let mut schema_changed = false;
for write in writes {
finalize_table_snapshot(
&mut tx,
snapshot_id,
&mut schema_version,
&mut schema_changed,
write.table_id,
&write.schema_name,
&write.table_name,
&write.columns,
&write.column_ids,
write.mode,
write.base_snapshot_id,
)
.await?;
}
let mut tables = Vec::with_capacity(writes.len());
for (write, (had_files, had_inlined_rows)) in writes.iter().zip(had_live_data) {
match &write.data {
StagedTableData::Files(files) => {
commit_files_at_snapshot(&mut tx, snapshot_id, write, files).await?;
},
StagedTableData::Inlined(batches) => {
commit_inlined_at_snapshot(&mut tx, snapshot_id, write, batches).await?;
},
StagedTableData::None => {},
}
apply_positional_deletes(
&mut tx,
write.table_id,
snapshot_id,
write.base_snapshot_id,
&write.positional_deletes,
)
.await?;
apply_inlined_deletes(
&mut tx,
write.table_id,
snapshot_id,
write.base_snapshot_id,
&write.inlined_deletes,
)
.await?;
let changes_made = staged_table_write_changes(write, had_files, had_inlined_rows);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(write.table_id)
.fetch_one(&mut *tx)
.await?;
tables.push(CommitIds {
snapshot_id,
schema_id,
table_id: write.table_id,
});
}
tx.commit().await?;
Ok(MultiTableCommit {
snapshot_id,
tables,
})
})
}
#[allow(clippy::too_many_arguments)]
fn set_delete_file(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
data_file_id: i64,
expected_prev_delete_file: Option<i64>,
base_snapshot: i64,
delete: &DeleteFileInfo,
) -> Result<CommitIds> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"delete targets data file {data_file_id}, which was retired by a \
concurrent write since snapshot {base_snapshot}; retry against the \
new generation"
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"delete on data file {data_file_id} conflicts with a concurrent delete \
(expected live delete file {expected_prev_delete_file:?}, found \
{current_prev:?}); retry against the new generation"
)));
}
if let Some(prev) = expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE delete_file_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(data_file_id)
.bind(table_id)
.bind(&delete.path)
.bind(delete.path_is_relative)
.bind(delete.file_size_bytes)
.bind(delete.footer_size)
.bind(delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let changes_made = format!("deleted_from_table:{table_id}");
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_deletes(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
file: &DataFileInfo,
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
self.register_data_file_with_deletes_and_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
file,
deletes,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_deletes_and_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
file: &DataFileInfo,
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
validate_delete_entries(mode, deletes)?;
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
if mode != WriteMode::Replace
&& let Some(expected_base_snapshot_id) = expected_base_snapshot_id
{
detect_replace_conflict(&mut tx, table_id, expected_base_snapshot_id).await?;
}
if !deletes.is_empty() {
let file_ids = deletes
.iter()
.map(|entry| entry.data_file_id)
.collect::<Vec<_>>();
detect_new_inlined_deletes(&mut tx, table_id, base_snapshot, &file_ids).await?;
}
let live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let stats_row =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let next_row_id: i64 = stats_row.try_get(0)?;
let row_ids = crate::metadata_writer::allocate_row_id_start(next_row_id, file);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_ids.stored)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let data_file_id: i64 = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &file.column_stats).await?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).await?;
recompute_table_column_stats(&mut tx, table_id, columns, column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(row_ids.advance)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: the file is no longer \
live as of the catalog's current head (retired since snapshot \
{base_snapshot}). This happens when another writer committed a \
Replace/compaction, OR when an earlier write in THIS session already \
advanced the catalog (the catalog pins its snapshot at creation and does \
not refresh). Re-open the catalog at the latest snapshot and retry.",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: its live delete file \
changed from {:?} to {current_prev:?} since snapshot {base_snapshot}. \
Another writer committed a delete on this file, OR an earlier \
UPDATE/DELETE in THIS session did (the catalog pins its snapshot at \
creation and does not refresh). Re-open the catalog at the latest \
snapshot and retry.",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(prev) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE delete_file_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
let replaced_existing_data = replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made =
table_write_changes(table_id, mode, !deletes.is_empty(), replaced_existing_data);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata).await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_deletes(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
self.register_data_files_with_deletes_and_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
files,
deletes,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_deletes_and_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
files: &[DataFileInfo],
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if files.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"register_data_files_with_deletes: files must be non-empty".to_string(),
));
}
validate_delete_entries(mode, deletes)?;
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
if mode != WriteMode::Replace
&& let Some(expected_base_snapshot_id) = expected_base_snapshot_id
{
detect_replace_conflict(&mut tx, table_id, expected_base_snapshot_id).await?;
}
if !deletes.is_empty() {
let file_ids = deletes
.iter()
.map(|entry| entry.data_file_id)
.collect::<Vec<_>>();
detect_new_inlined_deletes(&mut tx, table_id, base_snapshot, &file_ids).await?;
}
let live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
for file in files {
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
}
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let mut next_row_id: i64 =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let mut total_advance: i64 = 0;
let mut total_records: i64 = 0;
let mut total_bytes: i64 = 0;
for file in files {
let row_ids = crate::metadata_writer::allocate_row_id_start(next_row_id, file);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_ids.stored)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let data_file_id: i64 = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &file.column_stats)
.await?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).await?;
next_row_id += row_ids.advance;
total_advance += row_ids.advance;
total_records += file.record_count;
total_bytes += file.file_size_bytes;
}
recompute_table_column_stats(&mut tx, table_id, columns, column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(total_advance)
.bind(total_records)
.bind(total_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: the file is no longer \
live as of the catalog's current head (retired since snapshot \
{base_snapshot}). This happens when another writer committed a \
Replace/compaction, OR when an earlier write in THIS session already \
advanced the catalog (the catalog pins its snapshot at creation and does \
not refresh). Re-open the catalog at the latest snapshot and retry.",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: its live delete file \
changed from {:?} to {current_prev:?} since snapshot {base_snapshot}. \
Another writer committed a delete on this file, OR an earlier \
UPDATE/DELETE in THIS session did (the catalog pins its snapshot at \
creation and does not refresh). Re-open the catalog at the latest \
snapshot and retry.",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(prev) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE delete_file_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
let replaced_existing_data = replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made =
table_write_changes(table_id, mode, !deletes.is_empty(), replaced_existing_data);
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, commit_metadata).await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_positional_deletes(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
base_snapshot: i64,
deletes: &[DeleteFileEntry],
) -> Result<CommitIds> {
if deletes.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_positional_deletes requires at least one delete entry".to_string(),
));
}
validate_delete_entries(WriteMode::Append, deletes)?;
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
let file_ids = deletes
.iter()
.map(|entry| entry.data_file_id)
.collect::<Vec<_>>();
detect_new_inlined_deletes(&mut tx, table_id, base_snapshot, &file_ids).await?;
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: the file is no longer live as \
of the catalog's current head (retired since snapshot {base_snapshot}). \
This happens when another writer committed a Replace/compaction, OR when \
an earlier write in THIS session already advanced the catalog (the \
catalog pins its snapshot at creation and does not refresh). Re-open the \
catalog at the latest snapshot and retry.",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: its live delete file changed \
from {:?} to {current_prev:?} since snapshot {base_snapshot}. Another \
writer committed a delete on this file, OR an earlier DELETE in THIS \
session did (the catalog pins its snapshot at creation and does not \
refresh). Re-open the catalog at the latest snapshot and retry.",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(prev) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE delete_file_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
let changes_made = format!("deleted_from_table:{table_id}");
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_inlined_deletes(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
base_snapshot: i64,
rows: &[InlinedRowRef],
) -> Result<CommitIds> {
if rows.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_inlined_deletes requires at least one row".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
apply_inlined_deletes(&mut tx, table_id, snapshot_id, base_snapshot, rows).await?;
let changes_made = format!("inlined_delete:{table_id}");
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_deletes(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
base_snapshot: i64,
positional: &[DeleteFileEntry],
inlined: &[InlinedRowRef],
) -> Result<CommitIds> {
if inlined.is_empty() {
return self.commit_positional_deletes(
table_id,
schema_name,
table_name,
base_snapshot,
positional,
);
}
if positional.is_empty() {
return self.commit_inlined_deletes(
table_id,
schema_name,
table_name,
base_snapshot,
inlined,
);
}
validate_delete_entries(WriteMode::Append, positional)?;
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
apply_positional_deletes(&mut tx, table_id, snapshot_id, base_snapshot, positional)
.await?;
apply_inlined_deletes(&mut tx, table_id, snapshot_id, base_snapshot, inlined).await?;
let changes_made =
table_storage_changes(table_id, None, !positional.is_empty(), !inlined.is_empty());
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_compaction(
&self,
table_id: i64,
base_snapshot: i64,
sources: &[crate::metadata_writer::CompactionSourceFile],
outputs: &[crate::metadata_writer::CompactionOutputFile],
retirement: crate::metadata_writer::SourceRetirement,
) -> Result<CommitIds> {
use crate::metadata_writer::SourceRetirement;
if sources.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_compaction requires at least one source file".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
let source_data_ids = sources
.iter()
.map(|source| source.data_file_id)
.collect::<Vec<_>>();
detect_new_inlined_deletes(&mut tx, table_id, base_snapshot, &source_data_ids).await?;
for src in sources {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE data_file_id = ? AND table_id = ? AND end_snapshot IS NULL",
)
.bind(src.data_file_id)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"compaction of table {table_id} could not commit: source data file {} is \
no longer live (retired by a concurrent Replace/compaction since \
snapshot {base_snapshot}). Re-open the catalog at the latest snapshot \
and re-plan.",
src.data_file_id
)));
}
let current_delete: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = ? AND end_snapshot IS NULL",
)
.bind(src.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_delete != src.delete_file_id {
return Err(crate::DuckLakeError::Conflict(format!(
"compaction of table {table_id} could not commit: the live delete file of \
source data file {} changed from {:?} to {current_delete:?} since \
snapshot {base_snapshot} (a concurrent DELETE/UPDATE). Re-open the \
catalog at the latest snapshot and re-plan.",
src.data_file_id, src.delete_file_id
)));
}
}
match retirement {
SourceRetirement::Remove => {
let dead_data = sqlx::query(AssertSqlSafe(format!(
"SELECT df.data_file_id, {RESOLVED_PATH} AS resolved_path, {REL_FLAG} AS rel
FROM ducklake_data_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE df.data_file_id IN ({})",
id_list(&source_data_ids)
)))
.fetch_all(&mut *tx)
.await?;
schedule_files(&mut tx, dead_data).await?;
let dead_del = sqlx::query(AssertSqlSafe(format!(
"SELECT df.delete_file_id, {RESOLVED_PATH} AS resolved_path, {REL_FLAG} AS rel
FROM ducklake_delete_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE df.data_file_id IN ({})",
id_list(&source_data_ids)
)))
.fetch_all(&mut *tx)
.await?;
schedule_files(&mut tx, dead_del).await?;
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_delete_file WHERE data_file_id IN ({})",
id_list(&source_data_ids)
)))
.execute(&mut *tx)
.await?;
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_data_file WHERE data_file_id IN ({})",
id_list(&source_data_ids)
)))
.execute(&mut *tx)
.await?;
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_file_column_stats WHERE data_file_id IN ({})",
id_list(&source_data_ids)
)))
.execute(&mut *tx)
.await?;
sqlx::query(AssertSqlSafe(format!(
"DELETE FROM ducklake_file_partition_value WHERE data_file_id IN ({})",
id_list(&source_data_ids)
)))
.execute(&mut *tx)
.await?;
},
SourceRetirement::Retire => {
sqlx::query(AssertSqlSafe(format!(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE data_file_id IN ({}) AND end_snapshot IS NULL",
id_list(&source_data_ids)
)))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
sqlx::query(AssertSqlSafe(format!(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE data_file_id IN ({}) AND end_snapshot IS NULL",
id_list(&source_data_ids)
)))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
},
}
for out in outputs {
let begin = out.begin_snapshot.unwrap_or(snapshot_id);
sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot, partial_max)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)",
)
.bind(table_id)
.bind(&out.file.path)
.bind(out.file.path_is_relative)
.bind(out.file.file_size_bytes)
.bind(out.file.footer_size)
.bind(out.file.record_count)
.bind(begin)
.bind(out.partial_max)
.execute(&mut *tx)
.await?;
let data_file_id: i64 = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &out.file.column_stats)
.await?;
insert_partition_metadata(&mut tx, table_id, data_file_id, &out.file).await?;
}
sqlx::query(
"INSERT OR IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats SET
record_count = (SELECT COALESCE(SUM(record_count), 0)
FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL),
file_size_bytes = (SELECT COALESCE(SUM(file_size_bytes), 0)
FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL)
WHERE table_id = ?",
)
.bind(table_id)
.bind(table_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let changes_made = format!("compacted_table:{table_id}");
let commit_metadata =
SnapshotCommitMetadata::new().with_message("datafusion compaction");
record_snapshot_changes(&mut tx, snapshot_id, &changes_made, &commit_metadata).await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn retire_appends_since(&self, table_id: i64, base_snapshot: i64) -> Result<Option<i64>> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
let impure_delete: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_delete_file
WHERE table_id = ? AND begin_snapshot > ? LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.fetch_optional(&mut *tx)
.await?;
let impure_ended: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND begin_snapshot <= ?
AND end_snapshot IS NOT NULL AND end_snapshot > ? LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut *tx)
.await?;
let impure_column: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_column
WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?) LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut *tx)
.await?;
let impure_partition: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_partition_info
WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?) LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut *tx)
.await?;
if impure_delete.is_some()
|| impure_ended.is_some()
|| impure_column.is_some()
|| impure_partition.is_some()
{
return Err(crate::DuckLakeError::Conflict(format!(
"table {table_id}: changes since snapshot {base_snapshot} are not a pure \
append (delete/replace/update or schema/partition change present); refusing to retire"
)));
}
let has_appended: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL AND begin_snapshot > ? LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.fetch_optional(&mut *tx)
.await?;
if has_appended.is_none() {
return Ok(None);
}
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL AND begin_snapshot > ?",
)
.bind(snapshot_id)
.bind(table_id)
.bind(base_snapshot)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats SET
record_count = (SELECT COALESCE(SUM(record_count), 0)
FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL),
file_size_bytes = (SELECT COALESCE(SUM(file_size_bytes), 0)
FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL)
WHERE table_id = ?",
)
.bind(table_id)
.bind(table_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let (columns, column_ids) = live_columns_for_stats(table_id, &mut tx).await?;
recompute_table_column_stats(&mut tx, table_id, &columns, &column_ids).await?;
let changes_made = format!("deleted_from_table:{table_id}");
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(Some(snapshot_id))
})
}
fn commit_truncate(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_base_snapshot: i64,
) -> Result<u64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
let has_live_data: Option<i64> = sqlx::query_scalar(
"SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot IS NULL LIMIT 1",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
let live_inlined = live_inlined_row_count(&mut tx, table_id).await?;
if has_live_data.is_none() && live_inlined == 0 {
return Ok(0);
}
let gross: Option<i64> = sqlx::query_scalar(
"SELECT COALESCE(SUM(record_count), 0) FROM ducklake_data_file WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
let deleted: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(delete_count), 0) FROM ducklake_delete_file
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let inlined_table = crate::metadata_provider::inlined_delete_table_name(table_id)?;
let inlined_exists: Option<i64> =
sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(&inlined_table)
.fetch_optional(&mut *tx)
.await?;
let inlined_deleted: i64 = if inlined_exists.is_some() {
sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM (
SELECT DISTINCT d.file_id, d.row_id
FROM \"{inlined_table}\" d
JOIN ducklake_data_file f ON f.data_file_id = d.file_id
WHERE f.table_id = ? AND f.end_snapshot IS NULL
AND d.begin_snapshot <= ?
)"
)))
.bind(table_id)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?
} else {
0
};
let live_rows =
(gross.unwrap_or(0) + live_inlined - deleted - inlined_deleted).max(0) as u64;
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
for table_name in inlined_table_names(&mut tx, table_id).await? {
let sql = format!(
"UPDATE {} SET end_snapshot = ? WHERE end_snapshot IS NULL",
quote_ident(&table_name)
);
sqlx::query(AssertSqlSafe(sql))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats SET record_count = 0, file_size_bytes = 0
WHERE table_id = ?",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let changes_made = format!("deleted_from_table:{table_id}");
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(live_rows)
})
}
fn publish_snapshot(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id = finalize_snapshot(
&mut tx,
table_id,
schema_name,
table_name,
columns,
column_ids,
mode,
base_snapshot,
)
.await?;
let replaced_existing_data = replaced_storage(&mut tx, table_id, snapshot_id).await?;
let changes_made = table_write_changes(table_id, mode, false, replaced_existing_data);
record_snapshot_changes(
&mut tx,
snapshot_id,
&changes_made,
&SnapshotCommitMetadata::default(),
)
.await?;
let schema_id: i64 =
sqlx::query("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn end_table_files(&self, table_id: i64, snapshot_id: i64) -> Result<u64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let result = sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET record_count = 0, file_size_bytes = 0
WHERE table_id = ?",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(result.rows_affected())
})
}
fn get_data_path(&self) -> Result<String> {
block_on(async {
let row =
sqlx::query("SELECT value FROM ducklake_metadata WHERE key = ? AND scope IS NULL")
.bind("data_path")
.fetch_optional(&self.pool)
.await?;
match row {
Some(r) => Ok(r.try_get(0)?),
None => Err(crate::error::DuckLakeError::InvalidConfig(
"Missing required catalog metadata: 'data_path' not configured.".to_string(),
)),
}
})
}
fn set_data_path(&self, path: &str) -> Result<()> {
block_on(async {
sqlx::query("DELETE FROM ducklake_metadata WHERE key = 'data_path' AND scope IS NULL")
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope)
VALUES ('data_path', ?, NULL)",
)
.bind(path)
.execute(&self.pool)
.await?;
Ok(())
})
}
fn initialize_schema(&self) -> Result<()> {
block_on(async {
sqlx::query(SQL_CREATE_SCHEMA).execute(&self.pool).await?;
sqlx::query(SQL_CREATE_INLINED_DATA_TABLES)
.execute(&self.pool)
.await?;
migrate_ducklake_column_drop_pk(&self.pool).await?;
migrate_add_metadata_scope_columns(&self.pool).await?;
migrate_add_schema_version(&self.pool).await?;
migrate_add_partial_max(&self.pool).await?;
migrate_add_partition_id(&self.pool).await?;
migrate_snapshot_changes_nullable(&self.pool).await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope)
SELECT 'next_column_id',
CAST(COALESCE((SELECT MAX(column_id) FROM ducklake_column), 0) AS TEXT),
NULL
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_metadata WHERE key = 'next_column_id' AND scope IS NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope)
SELECT 'next_partition_id',
CAST(COALESCE((SELECT MAX(partition_id) FROM ducklake_partition_info), 0) AS TEXT),
NULL
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_metadata WHERE key = 'next_partition_id' AND scope IS NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope)
SELECT 'next_sort_id',
CAST(COALESCE((SELECT MAX(sort_id) FROM ducklake_sort_info), 0) AS TEXT),
NULL
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_metadata WHERE key = 'next_sort_id' AND scope IS NULL
)",
)
.execute(&self.pool)
.await?;
Ok(())
})
}
fn begin_write_transaction(
&self,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
mode: WriteMode,
) -> Result<WriteSetupResult> {
validate_name(schema_name, "Schema")?;
validate_name(table_name, "Table")?;
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"Table must have at least one column".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let catalog_columns = catalog_column_defs(columns)?;
let n = catalog_columns.len() as i64;
let last_column_id = reserve_ids(&mut tx, "next_column_id", n).await?;
let fresh_ids: Vec<i64> = ((last_column_id - n + 1)..=last_column_id).collect();
let base_snapshot_id: i64 =
sqlx::query("SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let snapshot_id: i64 = base_snapshot_id + 1;
let schema_id: i64 = {
let existing = sqlx::query(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
)
.bind(schema_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
row.try_get(0)?
} else {
let row = sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, 1, ?) RETURNING schema_id",
)
.bind(schema_name)
.bind(schema_name)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
row.try_get(0)?
}
};
let table_id: i64 = {
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(table_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
row.try_get(0)?
} else {
let row = sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, 1, ?) RETURNING table_id",
)
.bind(schema_id)
.bind(table_name)
.bind(table_name)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
row.try_get(0)?
}
};
let rows = sqlx::query(
"SELECT column_name, column_type, nulls_allowed, column_id, parent_column
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut *tx)
.await?;
let mut existing_catalog_columns = Vec::with_capacity(rows.len());
for row in rows {
let name: String = row.try_get(0)?;
let col_type: String = row.try_get(1)?;
let _nullable: bool = row.try_get::<Option<bool>, _>(2)?.unwrap_or(true);
let cid: i64 = row.try_get(3)?;
let parent_column: Option<i64> = row.try_get(4)?;
existing_catalog_columns.push(ExistingCatalogColumn {
column_id: cid,
name,
ducklake_type: col_type,
parent_column,
});
}
let field_ids =
assign_column_ids(&catalog_columns, &existing_catalog_columns, &fresh_ids)?;
if !existing_catalog_columns.is_empty() {
use std::collections::HashMap;
let existing_map: HashMap<i64, &ExistingCatalogColumn> = existing_catalog_columns
.iter()
.map(|column| (column.column_id, column))
.collect();
for (new_column, column_id) in catalog_columns.iter().zip(&field_ids) {
if let Some(existing_column) = existing_map.get(column_id) {
let same_type =
catalog_column_type_equal(&existing_column.ducklake_type, new_column);
if !same_type {
return Err(crate::error::DuckLakeError::UnsupportedTypeChange {
operation: TypeChangeOperation::DataWrite {
mode: match mode {
WriteMode::Replace => TypeChangeWriteMode::Replace,
WriteMode::Append => TypeChangeWriteMode::Append,
},
},
column: new_column.name.clone(),
from: existing_column.ducklake_type.clone(),
to: new_column.ducklake_type.clone(),
});
}
} else if mode == WriteMode::Append
&& new_column.parent_index.is_none()
&& !new_column.is_nullable
{
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Schema evolution error: new column '{}' must be nullable. Adding non-nullable columns is not allowed.",
new_column.name
)));
}
}
}
tx.commit().await?;
Ok(WriteSetupResult {
snapshot_id,
base_snapshot_id,
schema_id,
table_id,
column_ids: top_level_column_ids(&catalog_columns, &field_ids)?,
field_ids,
})
})
}
}
async fn enforce_inline_partition_fence(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table_id: i64,
base_snapshot: i64,
) -> Result<()> {
let changed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_partition_info WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?)")
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_one(&mut **tx).await?;
if changed != 0 {
return Err(crate::DuckLakeError::Conflict(format!(
"partition spec for table {table_id} changed during inline write; retry the write"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DuckLakeCatalog;
use crate::metadata_provider::MetadataProvider;
use arrow::array::Int64Array;
use datafusion::prelude::SessionContext;
use tempfile::TempDir;
#[test]
fn sqlite_inlined_types_follow_ducklake_encodings() {
assert_eq!(inlined_sqlite_type(&DataType::Int32), "BIGINT");
assert_eq!(inlined_sqlite_type(&DataType::UInt64), "TEXT");
assert_eq!(inlined_sqlite_type(&DataType::Float64), "DOUBLE");
assert_eq!(inlined_sqlite_type(&DataType::Float32), "DOUBLE");
assert_eq!(inlined_sqlite_type(&DataType::Binary), "BLOB");
assert_eq!(inlined_sqlite_type(&DataType::BinaryView), "BLOB");
assert_eq!(
inlined_sqlite_type(&DataType::FixedSizeBinary(16)),
"VARCHAR"
);
assert_eq!(inlined_sqlite_type(&DataType::FixedSizeBinary(32)), "BLOB");
assert_eq!(inlined_sqlite_type(&DataType::Date32), "VARCHAR");
assert_eq!(
inlined_sqlite_type(&DataType::Timestamp(
arrow::datatypes::TimeUnit::Nanosecond,
None,
)),
"VARCHAR"
);
}
async fn create_test_writer() -> (SqliteMetadataWriter, TempDir) {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let writer = SqliteMetadataWriter::new_with_init(&conn_str)
.await
.unwrap();
(writer, temp_dir)
}
#[rstest::rstest]
#[tokio::test(flavor = "multi_thread")]
async fn staged_inline_changes_follow_storage_and_share_snapshot() {
let (writer, temp) = create_test_writer().await;
writer.set_data_path(temp.path().to_str().unwrap()).unwrap();
let columns = vec![ColumnDef::new("value", "BIGINT", false).unwrap()];
let batch = RecordBatch::try_from_iter(vec![(
"value",
Arc::new(Int64Array::from(vec![31, 47])) as Arc<dyn Array>,
)])
.unwrap();
let mut writes = Vec::new();
for (name, data) in [
(
"files",
StagedTableData::Files(vec![DataFileInfo::new("rows.parquet", 123, 3)]),
),
("inline", StagedTableData::Inlined(vec![batch.clone()])),
] {
let setup = writer
.begin_write_transaction("main", name, &columns, WriteMode::Append)
.unwrap();
writes.push(StagedTableWrite {
table_id: setup.table_id,
schema_name: "main".to_string(),
table_name: name.to_string(),
base_snapshot_id: setup.base_snapshot_id,
mode: WriteMode::Append,
columns: columns.clone(),
column_ids: setup.column_ids,
data,
snapshot_id_columns: Vec::new(),
positional_deletes: Vec::new(),
inlined_deletes: Vec::new(),
inlined_flush: false,
});
}
let commit = writer
.commit_multi_table(&writes, &SnapshotCommitMetadata::default(), None)
.unwrap();
let provider = crate::SqliteMetadataProvider::new(&format!(
"sqlite:{}",
temp.path().join("test.db").display()
))
.await
.unwrap();
let changes = provider.list_snapshot_changes().unwrap();
let changes = changes
.iter()
.find(|change| change.snapshot_id == commit.snapshot_id)
.unwrap()
.changes_made
.as_deref()
.unwrap();
assert_eq!(
changes,
format!(
"created_schema:\"main\",created_table:\"main\".\"files\",created_table:\"main\".\"inline\",inserted_into_table:{},inlined_insert:{}",
writes[0].table_id, writes[1].table_id
)
);
let table_columns = provider
.get_table_structure(writes[1].table_id, commit.snapshot_id)
.unwrap();
let inline = provider
.get_inlined_data_with_row_ids(writes[1].table_id, commit.snapshot_id, &table_columns)
.unwrap();
assert_eq!(inline.len(), 1);
assert_eq!(inline[0].row_ids, vec![0, 1]);
assert_eq!(inline[0].begin_snapshots, vec![commit.snapshot_id; 2]);
assert_eq!(inline[0].batch, batch);
let file_snapshot: i64 =
sqlx::query_scalar("SELECT begin_snapshot FROM ducklake_data_file WHERE table_id = ?")
.bind(writes[0].table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(file_snapshot, commit.snapshot_id);
let mut replacement = writes[1].clone();
replacement.base_snapshot_id = commit.snapshot_id;
replacement.mode = WriteMode::Replace;
let replaced = writer
.commit_multi_table(
&[replacement.clone()],
&SnapshotCommitMetadata::default(),
Some(commit.snapshot_id),
)
.unwrap();
let changes = provider.list_snapshot_changes().unwrap();
assert_eq!(
changes.last().unwrap().changes_made,
Some(format!(
"inlined_delete:{},inlined_insert:{}",
replacement.table_id, replacement.table_id
))
);
let current = provider
.get_inlined_data_with_row_ids(
replacement.table_id,
replaced.snapshot_id,
&table_columns,
)
.unwrap();
assert_eq!(current[0].row_ids, vec![2, 3]);
replacement.mode = WriteMode::Append;
replacement.base_snapshot_id = replaced.snapshot_id;
replacement.data = StagedTableData::None;
replacement.inlined_deletes = vec![InlinedRowRef {
table_name: current[0].table_name.clone(),
row_id: 2,
}];
let deleted = writer
.commit_multi_table(
&[replacement],
&SnapshotCommitMetadata::default(),
Some(replaced.snapshot_id),
)
.unwrap();
let remaining = provider
.get_inlined_data_with_row_ids(writes[1].table_id, deleted.snapshot_id, &table_columns)
.unwrap();
assert_eq!(remaining[0].row_ids, vec![3]);
assert_eq!(
remaining[0]
.batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values(),
&[47]
);
assert_eq!(
provider
.list_snapshot_changes()
.unwrap()
.last()
.unwrap()
.changes_made,
Some(format!("inlined_delete:{}", writes[1].table_id))
);
let gross: i64 =
sqlx::query_scalar("SELECT record_count FROM ducklake_table_stats WHERE table_id = ?")
.bind(writes[1].table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(gross, 2);
let mut update = writes[1].clone();
update.base_snapshot_id = deleted.snapshot_id;
update.data = StagedTableData::Inlined(vec![
RecordBatch::try_from_iter(vec![(
"value",
Arc::new(Int64Array::from(vec![59])) as Arc<dyn Array>,
)])
.unwrap(),
]);
update.inlined_deletes = [3, 3, 4, 999]
.into_iter()
.map(|row_id| InlinedRowRef {
table_name: remaining[0].table_name.clone(),
row_id,
})
.collect();
let updated = writer
.commit_multi_table(
&[update],
&SnapshotCommitMetadata::default(),
Some(deleted.snapshot_id),
)
.unwrap();
let latest = provider
.get_inlined_data_with_row_ids(writes[1].table_id, updated.snapshot_id, &table_columns)
.unwrap();
let gross: i64 =
sqlx::query_scalar("SELECT record_count FROM ducklake_table_stats WHERE table_id = ?")
.bind(writes[1].table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(latest[0].row_ids, vec![4]);
assert_eq!(
latest[0]
.batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values(),
&[59]
);
assert_eq!(gross, 3);
let mut stale = writes[1].clone();
stale.base_snapshot_id = deleted.snapshot_id;
stale.data = StagedTableData::None;
stale.inlined_deletes = vec![InlinedRowRef {
table_name: remaining[0].table_name.clone(),
row_id: 3,
}];
let error = writer
.commit_multi_table(&[stale], &SnapshotCommitMetadata::default(), None)
.unwrap_err();
assert!(matches!(error, crate::DuckLakeError::Conflict(_)));
assert_eq!(
provider.get_current_snapshot().unwrap(),
updated.snapshot_id
);
let ctx = SessionContext::new();
ctx.register_catalog("dl", Arc::new(DuckLakeCatalog::new(provider).unwrap()));
let actual = ctx
.sql("SELECT COUNT(*) FROM dl.main.inline")
.await
.unwrap()
.collect()
.await
.unwrap();
assert_eq!(
actual[0]
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values(),
&[1]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn migrate_legacy_column_pk_to_bare_shape() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&conn_str).await.unwrap();
sqlx::query(
"CREATE TABLE ducklake_column (
column_id INTEGER PRIMARY KEY,
table_id INTEGER NOT NULL,
column_name VARCHAR NOT NULL,
column_type VARCHAR NOT NULL,
column_order INTEGER NOT NULL,
nulls_allowed BOOLEAN DEFAULT 1,
initial_default VARCHAR,
default_value VARCHAR,
parent_column INTEGER,
default_value_type VARCHAR,
default_value_dialect VARCHAR,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (5, 1, 'id', 'int32', 0, 1, 1)",
)
.execute(&pool)
.await
.unwrap();
let dup = sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (5, 1, 'id', 'int64', 0, 1, 2)",
)
.execute(&pool)
.await;
assert!(
dup.is_err(),
"legacy single-row PK must reject a duplicate column_id"
);
migrate_ducklake_column_drop_pk(&pool).await.unwrap();
let row = sqlx::query(
"SELECT column_id, column_name, column_type FROM ducklake_column WHERE begin_snapshot = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.try_get::<i64, _>("column_id").unwrap(), 5);
assert_eq!(row.try_get::<String, _>("column_name").unwrap(), "id");
assert_eq!(row.try_get::<String, _>("column_type").unwrap(), "int32");
sqlx::query(
"INSERT INTO ducklake_column
(column_id, begin_snapshot, end_snapshot, table_id, column_order, column_name, column_type, nulls_allowed)
VALUES (5, 2, NULL, 1, 0, 'id', 'int64', 1)",
)
.execute(&pool)
.await
.unwrap();
let cnt: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_column WHERE column_id = 5")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
cnt, 2,
"two version rows sharing a column_id must coexist post-migration"
);
migrate_ducklake_column_drop_pk(&pool).await.unwrap();
let cnt2: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_column")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(cnt2, 2, "migration must be idempotent");
}
#[tokio::test(flavor = "multi_thread")]
async fn migrate_bare_column_table_adds_default_metadata() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("legacy-defaults.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&conn_str).await.unwrap();
sqlx::query(
"CREATE TABLE ducklake_column (
column_id BIGINT,
begin_snapshot BIGINT,
end_snapshot BIGINT,
table_id BIGINT,
column_order BIGINT,
column_name VARCHAR,
column_type VARCHAR,
nulls_allowed BOOLEAN,
parent_column BIGINT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_column
(column_id, begin_snapshot, table_id, column_order, column_name,
column_type, nulls_allowed)
VALUES (5, 1, 1, 0, 'id', 'int32', 1)",
)
.execute(&pool)
.await
.unwrap();
migrate_ducklake_column_drop_pk(&pool).await.unwrap();
let default_columns: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM pragma_table_info('ducklake_column')
WHERE name IN ('initial_default', 'default_value',
'default_value_type', 'default_value_dialect')",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(default_columns, 4);
let row: (i64, String) =
sqlx::query_as("SELECT column_id, column_name FROM ducklake_column WHERE table_id = 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (5, "id".to_string()));
}
#[tokio::test(flavor = "multi_thread")]
async fn migrate_snapshot_changes_to_nullable() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&conn_str).await.unwrap();
sqlx::query(
"CREATE TABLE ducklake_snapshot_changes (
snapshot_id INTEGER PRIMARY KEY,
changes_made VARCHAR NOT NULL,
author VARCHAR,
commit_message VARCHAR,
commit_extra_info VARCHAR
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_snapshot_changes
(snapshot_id, changes_made, author, commit_message, commit_extra_info)
VALUES (1, '', NULL, NULL, NULL),
(2, 'inserted_into_table:7', 'author', 'message', 'extra')",
)
.execute(&pool)
.await
.unwrap();
migrate_snapshot_changes_nullable(&pool).await.unwrap();
let not_null: i64 = sqlx::query_scalar(
"SELECT `notnull` FROM pragma_table_info('ducklake_snapshot_changes')
WHERE name = 'changes_made'",
)
.fetch_one(&pool)
.await
.unwrap();
let rows = sqlx::query_as::<
_,
(
i64,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
"SELECT snapshot_id, changes_made, author, commit_message, commit_extra_info
FROM ducklake_snapshot_changes ORDER BY snapshot_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(not_null, 0);
assert_eq!(
rows,
vec![
(1, None, None, None, None),
(
2,
Some("inserted_into_table:7".to_string()),
Some("author".to_string()),
Some("message".to_string()),
Some("extra".to_string()),
),
],
);
migrate_snapshot_changes_nullable(&pool).await.unwrap();
let row_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_snapshot_changes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row_count, 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn open_legacy_snapshot_changes_with_plain_new() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let writer = SqliteMetadataWriter::new_with_init(&conn_str)
.await
.unwrap();
sqlx::query("DROP TABLE ducklake_snapshot_changes")
.execute(&writer.pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE ducklake_snapshot_changes (
snapshot_id INTEGER PRIMARY KEY,
changes_made VARCHAR NOT NULL
)",
)
.execute(&writer.pool)
.await
.unwrap();
drop(writer);
let writer = SqliteMetadataWriter::new(&conn_str).await.unwrap();
let snapshot_id = writer.create_snapshot().unwrap();
let changes_made: Option<String> = sqlx::query_scalar(
"SELECT changes_made FROM ducklake_snapshot_changes WHERE snapshot_id = ?",
)
.bind(snapshot_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(changes_made, None);
let commit_metadata = SnapshotCommitMetadata::new()
.with_author("Jane Doe")
.with_message("Imported official catalog")
.with_extra_info("migration-regression");
let mut tx = writer.pool.begin().await.unwrap();
record_snapshot_changes(
&mut tx,
snapshot_id,
"inserted_into_table:7",
&commit_metadata,
)
.await
.unwrap();
tx.commit().await.unwrap();
let columns = sqlx::query(
"SELECT name, `notnull` FROM pragma_table_info('ducklake_snapshot_changes')",
)
.fetch_all(&writer.pool)
.await
.unwrap()
.into_iter()
.map(|row| {
(
row.try_get::<String, _>("name").unwrap(),
row.try_get::<i64, _>("notnull").unwrap(),
)
})
.collect::<Vec<_>>();
let row = sqlx::query_as::<
_,
(
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
"SELECT changes_made, author, commit_message, commit_extra_info
FROM ducklake_snapshot_changes WHERE snapshot_id = ?",
)
.bind(snapshot_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(
columns,
vec![
("snapshot_id".to_string(), 0),
("changes_made".to_string(), 0),
("author".to_string(), 0),
("commit_message".to_string(), 0),
("commit_extra_info".to_string(), 0),
],
);
assert_eq!(
row,
(
Some("inserted_into_table:7".to_string()),
Some("Jane Doe".to_string()),
Some("Imported official catalog".to_string()),
Some("migration-regression".to_string()),
),
);
}
#[tokio::test(flavor = "multi_thread")]
async fn migrate_add_schema_version_to_legacy_snapshot() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let conn_str = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&conn_str).await.unwrap();
sqlx::query(
"CREATE TABLE ducklake_snapshot (
snapshot_id INTEGER PRIMARY KEY,
snapshot_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO ducklake_snapshot (snapshot_id) VALUES (1), (2)")
.execute(&pool)
.await
.unwrap();
migrate_add_schema_version(&pool).await.unwrap();
let has_col: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM pragma_table_info('ducklake_snapshot') WHERE name = 'schema_version'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(has_col, 1, "schema_version column must be added");
let max_v: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(schema_version), 0) FROM ducklake_snapshot")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
max_v, 0,
"pre-existing snapshots backfill to schema_version 0"
);
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_snapshot")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 2, "no snapshot rows lost in the migration");
migrate_add_schema_version(&pool).await.unwrap();
let rows2: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_snapshot")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows2, 2, "migration must be idempotent");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_create_snapshot() {
let (writer, _temp) = create_test_writer().await;
let snap1 = writer.create_snapshot().unwrap();
assert_eq!(snap1, 1);
let snap2 = writer.create_snapshot().unwrap();
assert_eq!(snap2, 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_or_create_schema() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = writer.create_snapshot().unwrap();
let (schema_id, created) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
assert!(created);
assert_eq!(schema_id, 1);
let (schema_id2, created2) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
assert!(!created2);
assert_eq!(schema_id2, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_or_create_table() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = writer.create_snapshot().unwrap();
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, created) = writer
.get_or_create_table(schema_id, "users", None, snapshot_id)
.unwrap();
assert!(created);
assert_eq!(table_id, 1);
let (table_id2, created2) = writer
.get_or_create_table(schema_id, "users", None, snapshot_id)
.unwrap();
assert!(!created2);
assert_eq!(table_id2, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_set_columns() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = writer.create_snapshot().unwrap();
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "users", None, snapshot_id)
.unwrap();
let columns = vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
];
let column_ids = writer.set_columns(table_id, &columns, snapshot_id).unwrap();
assert_eq!(column_ids.len(), 2);
assert_eq!(column_ids[0], 1);
assert_eq!(column_ids[1], 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn ddl_snapshots_record_spec_change_tokens() {
let (writer, _temp) = create_test_writer().await;
let bare_snapshot = writer.create_snapshot().unwrap();
let ddl_snapshot = writer.create_snapshot().unwrap();
let (schema_id, _) = writer
.get_or_create_schema("ma\"in", None, ddl_snapshot)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "ev\"ents", None, ddl_snapshot)
.unwrap();
writer
.set_columns(
table_id,
&[ColumnDef::new("id", "int64", false).unwrap()],
ddl_snapshot,
)
.unwrap();
let partition_snapshot = writer
.set_partition_spec(
table_id,
&[("id".to_string(), PartitionTransform::Identity)],
)
.unwrap();
let reset_snapshot = writer.reset_partition_spec(table_id).unwrap();
let rows = sqlx::query_as::<_, (i64, Option<String>)>(
"SELECT snapshot_id, changes_made
FROM ducklake_snapshot_changes
ORDER BY snapshot_id",
)
.fetch_all(&writer.pool)
.await
.unwrap();
assert_eq!(
rows,
vec![
(bare_snapshot, None),
(
ddl_snapshot,
Some(
"created_schema:\"ma\"\"in\",created_table:\"ma\"\"in\".\"ev\"\"ents\""
.to_string(),
),
),
(
partition_snapshot,
Some(format!("altered_table:{table_id}"))
),
(reset_snapshot, Some(format!("altered_table:{table_id}"))),
],
);
}
#[tokio::test(flavor = "multi_thread")]
async fn promote_leaves_two_versioned_column_rows_same_id() {
let (writer, _temp) = create_test_writer().await;
let snap1 = writer.create_snapshot().unwrap();
let (schema_id, _) = writer.get_or_create_schema("main", None, snap1).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap1)
.unwrap();
writer
.set_columns(
table_id,
&[ColumnDef::new("id", "int32", false).unwrap()],
snap1,
)
.unwrap();
let snap2 = writer.promote_column_type(table_id, "id", "int64").unwrap();
assert!(snap2 > snap1, "promote creates a newer snapshot");
let rows = sqlx::query(
"SELECT column_id, column_type, begin_snapshot, end_snapshot
FROM ducklake_column
WHERE table_id = ? AND column_name = 'id'
ORDER BY begin_snapshot",
)
.bind(table_id)
.fetch_all(&writer.pool)
.await
.unwrap();
assert_eq!(
rows.len(),
2,
"promote must leave TWO versioned rows for the column"
);
let cid0: i64 = rows[0].try_get("column_id").unwrap();
let type0: String = rows[0].try_get("column_type").unwrap();
let end0: Option<i64> = rows[0].try_get("end_snapshot").unwrap();
let cid1: i64 = rows[1].try_get("column_id").unwrap();
let type1: String = rows[1].try_get("column_type").unwrap();
let begin1: i64 = rows[1].try_get("begin_snapshot").unwrap();
let end1: Option<i64> = rows[1].try_get("end_snapshot").unwrap();
assert_eq!(
cid0, cid1,
"both versions share the SAME column_id (stable field-id)"
);
assert_eq!(type0, "int32", "old version retains its int32 type");
assert_eq!(
end0,
Some(snap2),
"old version retired at the promote snapshot"
);
assert_eq!(type1, "int64", "new version carries the widened int64 type");
assert_eq!(begin1, snap2, "new version begins at the promote snapshot");
assert_eq!(end1, None, "new version is the live one");
let live: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ducklake_column
WHERE table_id = ? AND column_name = 'id' AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(
live, 1,
"exactly one live version per field-id after promote"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn top_level_name_resolution_ignores_nested_collision() {
let (writer, _temp) = create_test_writer().await;
let snapshot = writer.create_snapshot().unwrap();
let (schema_id, _) = writer.get_or_create_schema("main", None, snapshot).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "events", None, snapshot)
.unwrap();
writer
.set_columns(
table_id,
&[ColumnDef::new("id", "int32", false).unwrap()],
snapshot,
)
.unwrap();
let top_level_id: i64 = sqlx::query_scalar(
"SELECT column_id FROM ducklake_column
WHERE table_id = ? AND column_name = 'id' AND parent_column IS NULL",
)
.bind(table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
let nested_id = top_level_id + 1;
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot)
VALUES (?, ?, 'id', 'int16', 1, 1, ?, ?)",
)
.bind(nested_id)
.bind(table_id)
.bind(top_level_id)
.bind(snapshot)
.execute(&writer.pool)
.await
.unwrap();
writer.promote_column_type(table_id, "id", "int64").unwrap();
writer
.set_partition_spec(
table_id,
&[("id".to_string(), PartitionTransform::Identity)],
)
.unwrap();
writer
.set_sort_spec(
table_id,
&[crate::sort::SortField::column(
0,
"id",
crate::sort::SortDirection::Asc,
crate::sort::NullOrder::NullsLast,
)],
)
.unwrap();
let top_level_type: String = sqlx::query_scalar(
"SELECT column_type FROM ducklake_column
WHERE table_id = ? AND column_name = 'id' AND parent_column IS NULL
AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
let nested: (String, Option<i64>) = sqlx::query_as(
"SELECT column_type, parent_column FROM ducklake_column
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.bind(nested_id)
.fetch_one(&writer.pool)
.await
.unwrap();
let partition_column_id: i64 = sqlx::query_scalar(
"SELECT column_id FROM ducklake_partition_column
WHERE table_id = ? ORDER BY partition_id DESC LIMIT 1",
)
.bind(table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(top_level_type, "int64");
assert_eq!(nested, ("int16".to_string(), Some(top_level_id)));
assert_eq!(partition_column_id, top_level_id);
}
#[tokio::test(flavor = "multi_thread")]
async fn phase_d_legacy_catalog_with_data_upgrades_and_reads() {
use crate::{DuckLakeCatalog, DuckLakeTableWriter, SqliteMetadataProvider};
use arrow::array::{Array, Int32Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::prelude::SessionContext;
use object_store::local::LocalFileSystem;
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();
let store: std::sync::Arc<dyn object_store::ObjectStore> =
std::sync::Arc::new(LocalFileSystem::new());
let schema =
std::sync::Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
let batch = RecordBatch::try_new(
schema,
vec![std::sync::Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
DuckLakeTableWriter::new(std::sync::Arc::new(writer), store)
.unwrap()
.write_table("main", "t", &[batch])
.await
.unwrap();
}
{
let pool = SqlitePool::connect(&conn_str).await.unwrap();
sqlx::query("ALTER TABLE ducklake_column RENAME TO ducklake_column__tmp")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE ducklake_column (
column_id INTEGER PRIMARY KEY,
table_id INTEGER NOT NULL,
column_name VARCHAR NOT NULL,
column_type VARCHAR NOT NULL,
column_order INTEGER NOT NULL,
nulls_allowed BOOLEAN DEFAULT 1,
initial_default VARCHAR,
default_value VARCHAR,
parent_column INTEGER,
default_value_type VARCHAR,
default_value_dialect VARCHAR,
begin_snapshot INTEGER NOT NULL,
end_snapshot INTEGER
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, begin_snapshot, end_snapshot)
SELECT column_id, table_id, column_name, column_type, column_order,
nulls_allowed, begin_snapshot, end_snapshot
FROM ducklake_column__tmp",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("DROP TABLE ducklake_column__tmp")
.execute(&pool)
.await
.unwrap();
let is_pk: i64 = sqlx::query_scalar(
"SELECT pk FROM pragma_table_info('ducklake_column') WHERE name = 'column_id'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(is_pk, 1, "downgrade produced the legacy single-row PK");
pool.close().await;
}
let writer = SqliteMetadataWriter::new_with_init(&conn_str)
.await
.unwrap();
let is_pk: i64 = sqlx::query_scalar(
"SELECT pk FROM pragma_table_info('ducklake_column') WHERE name = 'column_id'",
)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(is_pk, 0, "re-open migrated the legacy PK away");
let read_conn = format!("sqlite:{}", db_path.display());
let provider = SqliteMetadataProvider::new(&read_conn).await.unwrap();
let catalog = DuckLakeCatalog::new(provider).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog("test", std::sync::Arc::new(catalog));
let batches = ctx
.sql("SELECT id FROM test.main.t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let ids = batches[0]
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
assert_eq!(
ids.values(),
&[1, 2, 3],
"old-version data reads intact after upgrade"
);
let table_id: i64 =
sqlx::query_scalar("SELECT table_id FROM ducklake_table WHERE table_name = 't'")
.fetch_one(&writer.pool)
.await
.unwrap();
writer.promote_column_type(table_id, "id", "int64").unwrap();
let provider2 = SqliteMetadataProvider::new(&read_conn).await.unwrap();
let catalog2 = DuckLakeCatalog::new(provider2).unwrap();
let ctx2 = SessionContext::new();
ctx2.register_catalog("test", std::sync::Arc::new(catalog2));
let batches2 = ctx2
.sql("SELECT id FROM test.main.t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
assert_eq!(batches2[0].schema().field(0).data_type(), &DataType::Int64);
let ids2 = batches2[0]
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(
ids2.values(),
&[1, 2, 3],
"post-upgrade promote widens + reads intact"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_register_data_file() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = reserve_snapshot(&writer).await;
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "users", None, snapshot_id)
.unwrap();
let file = DataFileInfo::new("data.parquet", 1024, 100).with_footer_size(256);
let committed = writer
.register_data_file(
table_id,
"main",
"users",
snapshot_id,
&file,
WriteMode::Append,
0,
&[],
&[],
)
.unwrap();
assert_eq!(committed.snapshot_id, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn snapshot_changes_are_atomic_with_append_and_replace() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = reserve_snapshot(&writer).await;
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "users", None, snapshot_id)
.unwrap();
let appended = writer
.register_data_file(
table_id,
"main",
"users",
snapshot_id,
&DataFileInfo::new("append.parquet", 1024, 100),
WriteMode::Append,
0,
&[],
&[],
)
.unwrap();
let replacement_snapshot_id = reserve_snapshot(&writer).await;
let commit_metadata = SnapshotCommitMetadata::new()
.with_author("Jane Doe")
.with_message("Replace imported data")
.with_extra_info("opaque-application-value");
let replaced = writer
.register_data_file_with_commit_metadata(
table_id,
"main",
"users",
replacement_snapshot_id,
&DataFileInfo::new("replace.parquet", 512, 50),
WriteMode::Replace,
appended.snapshot_id,
&[],
&[],
&commit_metadata,
None,
)
.unwrap();
let rows = sqlx::query(
"SELECT snapshot_id, changes_made, author, commit_message, commit_extra_info
FROM ducklake_snapshot_changes
ORDER BY snapshot_id",
)
.fetch_all(&writer.pool)
.await
.unwrap();
let actual = rows
.iter()
.map(|row| {
(
row.try_get::<i64, _>("snapshot_id").unwrap(),
row.try_get::<String, _>("changes_made").unwrap(),
row.try_get::<Option<String>, _>("author").unwrap(),
row.try_get::<Option<String>, _>("commit_message").unwrap(),
row.try_get::<Option<String>, _>("commit_extra_info")
.unwrap(),
)
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![
(
appended.snapshot_id,
format!(
"created_schema:\"main\",created_table:\"main\".\"users\",\
inserted_into_table:{table_id}"
),
None,
None,
None,
),
(
replaced.snapshot_id,
format!("deleted_from_table:{table_id},inserted_into_table:{table_id}"),
Some("Jane Doe".to_string()),
Some("Replace imported data".to_string()),
Some("opaque-application-value".to_string()),
),
],
);
}
#[tokio::test(flavor = "multi_thread")]
async fn initial_replace_records_insertion_without_deletion() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = reserve_snapshot(&writer).await;
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "events", None, snapshot_id)
.unwrap();
let committed = writer
.register_data_file(
table_id,
"main",
"events",
snapshot_id,
&DataFileInfo::new("initial.parquet", 100, 3),
WriteMode::Replace,
0,
&[],
&[],
)
.unwrap();
let changes_made: String = sqlx::query_scalar(
"SELECT changes_made
FROM ducklake_snapshot_changes
WHERE snapshot_id = ?",
)
.bind(committed.snapshot_id)
.fetch_one(&writer.pool)
.await
.unwrap();
assert_eq!(
changes_made,
format!(
"created_schema:\"main\",created_table:\"main\".\"events\",\
inserted_into_table:{table_id}"
),
);
}
async fn reserve_snapshot(writer: &SqliteMetadataWriter) -> i64 {
sqlx::query("SELECT COALESCE(MAX(snapshot_id), 0) + 1 FROM ducklake_snapshot")
.fetch_one(&writer.pool)
.await
.unwrap()
.try_get(0)
.unwrap()
}
async fn read_row_id_start(writer: &SqliteMetadataWriter, path: &str) -> Option<i64> {
let row = sqlx::query("SELECT row_id_start FROM ducklake_data_file WHERE path = ?")
.bind(path)
.fetch_one(&writer.pool)
.await
.unwrap();
row.try_get(0).ok()
}
async fn read_table_stats(writer: &SqliteMetadataWriter, table_id: i64) -> (i64, i64, i64) {
let row = sqlx::query(
"SELECT record_count, next_row_id, file_size_bytes
FROM ducklake_table_stats WHERE table_id = ?",
)
.bind(table_id)
.fetch_one(&writer.pool)
.await
.unwrap();
(
row.try_get(0).unwrap(),
row.try_get(1).unwrap(),
row.try_get(2).unwrap(),
)
}
#[allow(clippy::type_complexity)]
async fn read_file_column_stats(
writer: &SqliteMetadataWriter,
path: &str,
) -> Vec<(
i64,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
)> {
sqlx::query(
"SELECT s.column_id, s.min_value, s.max_value, s.null_count, s.value_count
FROM ducklake_file_column_stats s
JOIN ducklake_data_file d ON d.data_file_id = s.data_file_id
WHERE d.path = ?
ORDER BY s.column_id",
)
.bind(path)
.fetch_all(&writer.pool)
.await
.unwrap()
.into_iter()
.map(|row| {
(
row.try_get(0).unwrap(),
row.try_get(1).unwrap(),
row.try_get(2).unwrap(),
row.try_get(3).unwrap(),
row.try_get(4).unwrap(),
)
})
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn register_data_file_persists_column_stats() {
let (writer, _temp) = create_test_writer().await;
let snap = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, snap).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap)
.unwrap();
let file = DataFileInfo::new("a.parquet", 100, 3).with_column_stats(vec![
ColumnStat {
column_id: 1,
min_value: Some("1".to_string()),
max_value: Some("3".to_string()),
null_count: Some(0),
value_count: Some(3),
contains_nan: None,
column_size_bytes: None,
},
ColumnStat {
column_id: 2,
min_value: Some("Alice".to_string()),
max_value: Some("Bob".to_string()),
null_count: Some(1),
value_count: Some(2),
contains_nan: None,
column_size_bytes: None,
},
]);
writer
.register_data_file(
table_id,
"main",
"t",
snap,
&file,
WriteMode::Append,
0,
&[],
&[],
)
.unwrap();
let rows = read_file_column_stats(&writer, "a.parquet").await;
assert_eq!(
rows,
vec![
(
1,
Some("1".to_string()),
Some("3".to_string()),
Some(0),
Some(3)
),
(
2,
Some("Alice".to_string()),
Some("Bob".to_string()),
Some(1),
Some(2)
),
]
);
}
async fn read_table_column_stats(
writer: &SqliteMetadataWriter,
table_id: i64,
) -> Vec<(i64, Option<bool>, Option<String>, Option<String>)> {
sqlx::query(
"SELECT column_id, contains_null, min_value, max_value
FROM ducklake_table_column_stats WHERE table_id = ? ORDER BY column_id",
)
.bind(table_id)
.fetch_all(&writer.pool)
.await
.unwrap()
.into_iter()
.map(|row| {
(
row.try_get(0).unwrap(),
row.try_get(1).unwrap(),
row.try_get(2).unwrap(),
row.try_get(3).unwrap(),
)
})
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn table_column_stats_widen_numerically_across_files() {
let (writer, _temp) = create_test_writer().await;
let snap1 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, snap1).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap1)
.unwrap();
let columns = vec![ColumnDef::new("n", "int32", true).unwrap()];
let column_ids = vec![1_i64];
let stat = |min: &str, max: &str, nulls: i64, vals: i64| {
vec![ColumnStat {
column_id: 1,
min_value: Some(min.to_string()),
max_value: Some(max.to_string()),
null_count: Some(nulls),
value_count: Some(vals),
contains_nan: None,
column_size_bytes: None,
}]
};
writer
.register_data_file(
table_id,
"main",
"t",
snap1,
&DataFileInfo::new("a.parquet", 100, 3).with_column_stats(stat("9", "9", 0, 3)),
WriteMode::Append,
0,
&columns,
&column_ids,
)
.unwrap();
let snap2 = reserve_snapshot(&writer).await;
writer
.register_data_file(
table_id,
"main",
"t",
snap2,
&DataFileInfo::new("b.parquet", 100, 2).with_column_stats(stat("10", "10", 1, 1)),
WriteMode::Append,
0,
&columns,
&column_ids,
)
.unwrap();
assert_eq!(read_file_column_stats(&writer, "a.parquet").await.len(), 1);
assert_eq!(read_file_column_stats(&writer, "b.parquet").await.len(), 1);
assert_eq!(
read_table_column_stats(&writer, table_id).await,
vec![(1, Some(true), Some("9".to_string()), Some("10".to_string()))]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn table_column_stats_null_when_a_live_file_lacks_stats() {
let (writer, _temp) = create_test_writer().await;
let snap1 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, snap1).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap1)
.unwrap();
let columns = vec![ColumnDef::new("n", "int32", true).unwrap()];
let column_ids = vec![1_i64];
writer
.register_data_file(
table_id,
"main",
"t",
snap1,
&DataFileInfo::new("a.parquet", 100, 3).with_column_stats(vec![ColumnStat {
column_id: 1,
min_value: Some("1".to_string()),
max_value: Some("1".to_string()),
null_count: Some(0),
value_count: Some(3),
contains_nan: None,
column_size_bytes: None,
}]),
WriteMode::Append,
0,
&columns,
&column_ids,
)
.unwrap();
let snap2 = reserve_snapshot(&writer).await;
writer
.register_data_file(
table_id,
"main",
"t",
snap2,
&DataFileInfo::new("b.parquet", 100, 2),
WriteMode::Append,
0,
&columns,
&column_ids,
)
.unwrap();
assert_eq!(
read_table_column_stats(&writer, table_id).await,
vec![(1, None, None, None)],
"incomplete coverage (a statless live file) must NULL the roll-up"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn row_id_start_advances_across_inserts() {
let (writer, _temp) = create_test_writer().await;
let snap1 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, snap1).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap1)
.unwrap();
writer
.register_data_file(
table_id,
"main",
"t",
snap1,
&DataFileInfo::new("a.parquet", 100, 3),
WriteMode::Append,
0, &[],
&[],
)
.unwrap();
let snap2 = reserve_snapshot(&writer).await;
writer
.register_data_file(
table_id,
"main",
"t",
snap2,
&DataFileInfo::new("b.parquet", 250, 7),
WriteMode::Append,
0, &[],
&[],
)
.unwrap();
assert_eq!(read_row_id_start(&writer, "a.parquet").await, Some(0));
assert_eq!(read_row_id_start(&writer, "b.parquet").await, Some(3));
let (records, next, bytes) = read_table_stats(&writer, table_id).await;
assert_eq!(records, 10, "record_count = 3 + 7");
assert_eq!(next, 10, "next_row_id advances by sum of record_counts");
assert_eq!(bytes, 350, "file_size_bytes accumulates");
}
#[tokio::test(flavor = "multi_thread")]
async fn end_table_files_preserves_next_row_id() {
let (writer, _temp) = create_test_writer().await;
let snap1 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, snap1).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, snap1)
.unwrap();
writer
.register_data_file(
table_id,
"main",
"t",
snap1,
&DataFileInfo::new("a.parquet", 100, 5),
WriteMode::Append,
0, &[],
&[],
)
.unwrap();
let snap2 = writer.create_snapshot().unwrap();
writer.end_table_files(table_id, snap2).unwrap();
let (records, next, bytes) = read_table_stats(&writer, table_id).await;
assert_eq!(records, 0, "record_count cleared after end_table_files");
assert_eq!(next, 5, "next_row_id preserved (monotonic across lifetime)");
assert_eq!(bytes, 0, "file_size_bytes cleared");
let snap3 = reserve_snapshot(&writer).await;
writer
.register_data_file(
table_id,
"main",
"t",
snap3,
&DataFileInfo::new("b.parquet", 200, 2),
WriteMode::Append,
0, &[],
&[],
)
.unwrap();
assert_eq!(
read_row_id_start(&writer, "b.parquet").await,
Some(5),
"post-replace files must start at the preserved counter, not 0",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn row_id_start_works_when_stats_row_missing() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = reserve_snapshot(&writer).await;
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot_id)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "legacy", None, snapshot_id)
.unwrap();
sqlx::query("DELETE FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.execute(&writer.pool)
.await
.unwrap();
writer
.register_data_file(
table_id,
"main",
"legacy",
snapshot_id,
&DataFileInfo::new("a.parquet", 50, 4),
WriteMode::Append,
0, &[],
&[],
)
.unwrap();
assert_eq!(read_row_id_start(&writer, "a.parquet").await, Some(0));
let (records, next, _) = read_table_stats(&writer, table_id).await;
assert_eq!(records, 4);
assert_eq!(next, 4);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_end_table_files() {
let (writer, _temp) = create_test_writer().await;
let snapshot1 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer
.get_or_create_schema("main", None, snapshot1)
.unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "users", None, snapshot1)
.unwrap();
let file = DataFileInfo::new("data1.parquet", 1024, 100);
writer
.register_data_file(
table_id,
"main",
"users",
snapshot1,
&file,
WriteMode::Append,
0,
&[],
&[],
)
.unwrap();
let snapshot2 = writer.create_snapshot().unwrap();
let ended = writer.end_table_files(table_id, snapshot2).unwrap();
assert_eq!(ended, 1);
let ended2 = writer.end_table_files(table_id, snapshot2).unwrap();
assert_eq!(ended2, 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_data_path() {
let (writer, _temp) = create_test_writer().await;
writer.set_data_path("/data/path").unwrap();
let path = writer.get_data_path().unwrap();
assert_eq!(path, "/data/path");
writer.set_data_path("/new/path").unwrap();
let path2 = writer.get_data_path().unwrap();
assert_eq!(path2, "/new/path");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_or_create_schema_empty_name_rejected() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = writer.create_snapshot().unwrap();
let result = writer.get_or_create_schema("", None, snapshot_id);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("empty"), "Expected 'empty' in: {err_msg}");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_or_create_schema_control_char_rejected() {
let (writer, _temp) = create_test_writer().await;
let snapshot_id = writer.create_snapshot().unwrap();
let result = writer.get_or_create_schema("bad\0schema", None, snapshot_id);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("control character"),
"Expected 'control character' in: {err_msg}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_begin_write_transaction_empty_schema_name_rejected() {
let (writer, _temp) = create_test_writer().await;
let columns = vec![ColumnDef::new("id", "int64", false).unwrap()];
let result = writer.begin_write_transaction("", "table", &columns, WriteMode::Replace);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("empty"), "Expected 'empty' in: {err_msg}");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_begin_write_transaction_empty_table_name_rejected() {
let (writer, _temp) = create_test_writer().await;
let columns = vec![ColumnDef::new("id", "int64", false).unwrap()];
let result = writer.begin_write_transaction("main", "", &columns, WriteMode::Replace);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("empty"), "Expected 'empty' in: {err_msg}");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_begin_write_transaction_control_char_names_rejected() {
let (writer, _temp) = create_test_writer().await;
let columns = vec![ColumnDef::new("id", "int64", false).unwrap()];
let result =
writer.begin_write_transaction("bad\nschema", "table", &columns, WriteMode::Replace);
assert!(result.is_err());
let result =
writer.begin_write_transaction("main", "bad\ttable", &columns, WriteMode::Replace);
assert!(result.is_err());
}
async fn read_end_snapshot(writer: &SqliteMetadataWriter, path: &str) -> Option<i64> {
sqlx::query("SELECT end_snapshot FROM ducklake_data_file WHERE path = ?")
.bind(path)
.fetch_one(&writer.pool)
.await
.unwrap()
.try_get(0)
.unwrap()
}
async fn max_snapshot(writer: &SqliteMetadataWriter) -> i64 {
sqlx::query_scalar("SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot")
.fetch_one(&writer.pool)
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn retire_appends_since_rolls_back_pure_append() {
let (writer, _temp) = create_test_writer().await;
let s0 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, s0).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, s0)
.unwrap();
let file_a = DataFileInfo::new("a.parquet", 1000, 100).with_footer_size(64);
let base = writer
.register_data_file(
table_id,
"main",
"t",
s0,
&file_a,
WriteMode::Append,
0,
&[],
&[],
)
.unwrap()
.snapshot_id;
let s1 = reserve_snapshot(&writer).await;
let file_b = DataFileInfo::new("b.parquet", 2000, 50).with_footer_size(64);
writer
.register_data_file(
table_id,
"main",
"t",
s1,
&file_b,
WriteMode::Append,
base,
&[],
&[],
)
.unwrap();
let (rc_before, nri_before, size_before) = read_table_stats(&writer, table_id).await;
assert_eq!((rc_before, nri_before, size_before), (150, 150, 3000));
let new_snap = writer.retire_appends_since(table_id, base).unwrap();
assert!(new_snap.is_some(), "expected a compensating snapshot");
assert!(read_end_snapshot(&writer, "a.parquet").await.is_none());
assert!(read_end_snapshot(&writer, "b.parquet").await.is_some());
let (rc_after, nri_after, size_after) = read_table_stats(&writer, table_id).await;
assert_eq!(rc_after, 100, "record_count back to base");
assert_eq!(size_after, 1000, "file_size_bytes back to base");
assert_eq!(nri_after, 150, "next_row_id preserved");
let head = max_snapshot(&writer).await;
assert!(
writer
.retire_appends_since(table_id, base)
.unwrap()
.is_none()
);
assert_eq!(
max_snapshot(&writer).await,
head,
"no snapshot minted on no-op"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn retire_appends_since_refuses_non_append_delta() {
let (writer, _temp) = create_test_writer().await;
let s0 = reserve_snapshot(&writer).await;
let (schema_id, _) = writer.get_or_create_schema("main", None, s0).unwrap();
let (table_id, _) = writer
.get_or_create_table(schema_id, "t", None, s0)
.unwrap();
let file_a = DataFileInfo::new("a.parquet", 1000, 100).with_footer_size(64);
let base = writer
.register_data_file(
table_id,
"main",
"t",
s0,
&file_a,
WriteMode::Append,
0,
&[],
&[],
)
.unwrap()
.snapshot_id;
writer.commit_truncate(table_id, "main", "t", base).unwrap();
let err = writer.retire_appends_since(table_id, base).unwrap_err();
assert!(
matches!(err, crate::DuckLakeError::Conflict(_)),
"expected Conflict, got {err:?}"
);
}
}