#![cfg(feature = "write-postgres")]
use std::sync::Arc;
use arrow::array::{Array, Int32Array, RecordBatch};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::prelude::*;
use datafusion_ducklake::{
CompactionResult, DuckLakeCatalog, DuckLakeTable, DuckLakeTableWriter, MergeOptions,
MetadataProvider, MetadataWriter, MulticatalogManager, MulticatalogProvider,
PostgresMetadataWriter, RewriteOptions,
};
use object_store::local::LocalFileSystem;
use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions};
use tempfile::TempDir;
use testcontainers::ContainerAsync;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
type ObjStore = Arc<dyn object_store::ObjectStore>;
async fn spin_up_postgres() -> anyhow::Result<(PgPool, ContainerAsync<Postgres>)> {
let container = Postgres::default().start().await?;
let port = container.get_host_port_ipv4(5432).await?;
let conn_str = format!("postgresql://postgres:postgres@127.0.0.1:{}/postgres", port);
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&conn_str)
.await?;
datafusion_ducklake::initialize_multicatalog_schema(&pool).await?;
Ok((pool, container))
}
fn schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("val", DataType::Int32, false),
]))
}
fn batch(ids: Vec<i32>, vals: Vec<i32>) -> RecordBatch {
RecordBatch::try_new(
schema(),
vec![Arc::new(Int32Array::from(ids)), Arc::new(Int32Array::from(vals))],
)
.unwrap()
}
async fn writer_for(
pool: &PgPool,
cat: i64,
data_path: &std::path::Path,
) -> Arc<PostgresMetadataWriter> {
let w = PostgresMetadataWriter::with_pool(pool.clone(), cat)
.await
.unwrap();
w.set_data_path(data_path.to_str().unwrap()).unwrap();
Arc::new(w)
}
async fn read_rows(pool: &PgPool, cat_name: &str, snapshot: Option<i64>) -> Vec<(i32, i32)> {
let provider = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap();
let catalog = match snapshot {
Some(s) => DuckLakeCatalog::with_snapshot(Arc::new(provider), s).unwrap(),
None => DuckLakeCatalog::new(provider).unwrap(),
};
let ctx = SessionContext::new();
ctx.register_catalog(cat_name, Arc::new(catalog));
let batches = ctx
.sql(&format!(
"SELECT id, val FROM {cat_name}.public.t ORDER BY id"
))
.await
.unwrap()
.collect()
.await
.unwrap();
let mut rows = Vec::new();
for b in &batches {
let ids = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
let vals = b.column(1).as_any().downcast_ref::<Int32Array>().unwrap();
for i in 0..b.num_rows() {
rows.push((ids.value(i), vals.value(i)));
}
}
rows
}
async fn live_files(pool: &PgPool, cat_name: &str) -> Vec<datafusion_ducklake::DuckLakeTableFile> {
let provider = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap();
let head = provider.get_current_snapshot().unwrap();
let sch = provider
.get_schema_by_name("public", head)
.unwrap()
.unwrap();
let tbl = provider
.get_table_by_name(sch.schema_id, "t", head)
.unwrap()
.unwrap();
provider
.get_table_files_for_select(tbl.table_id, head)
.unwrap()
}
async fn scalar_i64(pool: &PgPool, sql: &str, cat: i64) -> i64 {
sqlx::query(sql)
.bind(cat)
.fetch_one(pool)
.await
.unwrap()
.try_get::<i64, _>(0)
.unwrap()
}
async fn with_writable_table<F, Fut>(
pool: &PgPool,
cat: i64,
cat_name: &str,
data: &std::path::Path,
op: F,
) -> CompactionResult
where
F: FnOnce(DuckLakeTable, datafusion::execution::SessionState) -> Fut,
Fut: std::future::Future<Output = datafusion_ducklake::Result<CompactionResult>>,
{
let provider = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap();
let writer = writer_for(pool, cat, data).await;
let catalog = DuckLakeCatalog::with_writer(Arc::new(provider), writer).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog(cat_name, Arc::new(catalog));
let provider = ctx
.catalog(cat_name)
.unwrap()
.schema("public")
.unwrap()
.table("t")
.await
.unwrap()
.unwrap();
let table = (provider.as_ref() as &dyn std::any::Any)
.downcast_ref::<DuckLakeTable>()
.expect("provider is a DuckLakeTable")
.clone();
op(table, ctx.state()).await.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
#[cfg_attr(all(feature = "skip-tests-with-docker", target_os = "macos"), ignore)]
async fn merge_adjacent_files_postgres() {
let (pool, _c) = spin_up_postgres().await.unwrap();
let tmp = TempDir::new().unwrap();
let data = tmp.path().join("data");
std::fs::create_dir_all(&data).unwrap();
let os: ObjStore = Arc::new(LocalFileSystem::new());
let cat_name = "cat";
let cat = MulticatalogManager::new(pool.clone())
.create_catalog(cat_name)
.await
.unwrap();
DuckLakeTableWriter::new(writer_for(&pool, cat, &data).await, os.clone())
.unwrap()
.write_table("public", "t", &[batch(vec![1, 2], vec![10, 20])])
.await
.unwrap();
let first = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap()
.get_current_snapshot()
.unwrap();
for (ids, vals) in [(vec![3, 4], vec![30, 40]), (vec![5, 6], vec![50, 60])] {
DuckLakeTableWriter::new(writer_for(&pool, cat, &data).await, os.clone())
.unwrap()
.append_table("public", "t", &[batch(ids, vals)])
.await
.unwrap();
}
let pre_merge = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap()
.get_current_snapshot()
.unwrap();
assert_eq!(live_files(&pool, cat_name).await.len(), 3, "three files");
let rows_before = vec![(1, 10), (2, 20), (3, 30), (4, 40), (5, 50), (6, 60)];
assert_eq!(read_rows(&pool, cat_name, None).await, rows_before);
let result = with_writable_table(&pool, cat, cat_name, &data, |t, s| async move {
t.merge_adjacent_files(&s, MergeOptions::default()).await
})
.await;
assert_eq!(result.files_processed, 3, "all three merged");
assert_eq!(result.files_created, 1);
let files = live_files(&pool, cat_name).await;
assert_eq!(files.len(), 1, "one merged file remains");
assert_eq!(
files[0].partial_max,
Some(pre_merge),
"partial_max = max origin snapshot"
);
assert_eq!(
scalar_i64(
&pool,
"SELECT COUNT(*) FROM ducklake_files_scheduled_for_deletion WHERE catalog_id = $1",
cat,
)
.await,
3,
);
assert_eq!(read_rows(&pool, cat_name, None).await, rows_before);
assert_eq!(
read_rows(&pool, cat_name, Some(pre_merge)).await,
rows_before
);
assert_eq!(
read_rows(&pool, cat_name, Some(first)).await,
vec![(1, 10), (2, 20)]
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg_attr(all(feature = "skip-tests-with-docker", target_os = "macos"), ignore)]
async fn rewrite_data_files_postgres() {
let (pool, _c) = spin_up_postgres().await.unwrap();
let tmp = TempDir::new().unwrap();
let data = tmp.path().join("data");
std::fs::create_dir_all(&data).unwrap();
let os: ObjStore = Arc::new(LocalFileSystem::new());
let cat_name = "cat";
let cat = MulticatalogManager::new(pool.clone())
.create_catalog(cat_name)
.await
.unwrap();
DuckLakeTableWriter::new(writer_for(&pool, cat, &data).await, os.clone())
.unwrap()
.write_table(
"public",
"t",
&[batch((1..=10).collect(), (1..=10).map(|v| v * 10).collect())],
)
.await
.unwrap();
{
let provider = MulticatalogProvider::with_pool(pool.clone(), cat_name)
.await
.unwrap();
let writer = writer_for(&pool, cat, &data).await;
let catalog = DuckLakeCatalog::with_writer(Arc::new(provider), writer).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog(cat_name, Arc::new(catalog));
ctx.sql(&format!("DELETE FROM {cat_name}.public.t WHERE id <= 8"))
.await
.unwrap()
.collect()
.await
.unwrap();
}
assert_eq!(
read_rows(&pool, cat_name, None).await,
vec![(9, 90), (10, 100)]
);
let result = with_writable_table(&pool, cat, cat_name, &data, |t, s| async move {
t.rewrite_data_files(
&s,
RewriteOptions {
delete_threshold: 0.5,
},
)
.await
})
.await;
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_created, 1);
assert_eq!(result.rows_written, 2);
let files = live_files(&pool, cat_name).await;
assert_eq!(files.len(), 1);
assert_eq!(
files[0].partial_max, None,
"a rewrite output is not partial"
);
assert_eq!(files[0].delete_file_id, None, "no live delete file");
assert_eq!(
read_rows(&pool, cat_name, None).await,
vec![(9, 90), (10, 100)]
);
}