use std::error::Error;
use std::sync::Arc;
use arrow::array::{Array, Int32Array, Int64Array, StringArray, StringViewArray};
use arrow::record_batch::RecordBatch;
use datafusion::execution::runtime_env::RuntimeEnv;
use datafusion::prelude::*;
use datafusion_ducklake::{DuckLakeCatalog, PostgresMetadataProvider};
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
use url::Url;
const TABLE: &str = "_rowid_verify_demo";
const QUERY: &str = "SELECT rowid, id, name FROM dl.main._rowid_verify_demo ORDER BY rowid";
struct Config {
pg_conn: String,
pg_uri: String,
s3_key: String,
s3_secret: String,
s3_endpoint: String,
s3_bucket: String,
s3_endpoint_host: String,
data_path: String,
}
fn load_config() -> Result<Config, Box<dyn Error>> {
fn required(var: &str) -> Result<String, Box<dyn Error>> {
std::env::var(var).map_err(|_| {
format!(
"{} is required — see the header comment for the full list of env vars",
var
)
.into()
})
}
Ok(Config {
pg_conn: required("DUCKLAKE_PG_CONN")?,
pg_uri: required("DUCKLAKE_PG_URI")?,
s3_key: required("DUCKLAKE_S3_KEY")?,
s3_secret: required("DUCKLAKE_S3_SECRET")?,
s3_endpoint: std::env::var("DUCKLAKE_S3_ENDPOINT")
.unwrap_or_else(|_| "https://t3.storage.dev".to_string()),
s3_bucket: std::env::var("DUCKLAKE_S3_BUCKET")
.unwrap_or_else(|_| "ducklake-storage".to_string()),
s3_endpoint_host: std::env::var("DUCKLAKE_S3_ENDPOINT_HOST")
.unwrap_or_else(|_| "t3.storage.dev".to_string()),
data_path: std::env::var("DUCKLAKE_DATA_PATH")
.unwrap_or_else(|_| "ducklake_demo/".to_string()),
})
}
#[derive(Debug, PartialEq, Eq, Clone)]
struct Row {
rowid: i64,
id: i64,
name: Option<String>,
}
fn header(title: &str) {
println!("\n========================================");
println!("{title}");
println!("========================================");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let cfg = load_config()?;
header("Setting up DuckDB ducklake reference path");
let duckdb_conn = setup_duckdb(&cfg)?;
header("Setting up S3 runtime for DataFusion");
let runtime = setup_runtime(&cfg)?;
let result = run_lifecycle(&duckdb_conn, &runtime, &cfg).await;
let _ = drop_table(&duckdb_conn);
println!("\n(cleanup) ✓ dropped {TABLE}");
result
}
async fn run_lifecycle(
duckdb_conn: &duckdb::Connection,
runtime: &Arc<RuntimeEnv>,
cfg: &Config,
) -> Result<(), Box<dyn Error>> {
let _ = drop_table(duckdb_conn);
let mut all_passed = true;
header("Step 1: CREATE TABLE + two INSERT batches");
duckdb_conn.execute_batch(&format!(
"CREATE TABLE dl.main.{TABLE}(id INTEGER, name VARCHAR);"
))?;
duckdb_conn.execute_batch(&format!(
"INSERT INTO dl.main.{TABLE} VALUES (1, 'alice'), (2, 'bob'), (3, 'charlie');"
))?;
duckdb_conn.execute_batch(&format!(
"INSERT INTO dl.main.{TABLE} VALUES (4, 'dave'), (5, 'eve');"
))?;
all_passed &= compare_step("After two INSERTs", duckdb_conn, runtime, cfg).await?;
header("Step 2: UPDATE id IN (1, 3) → file rewrite with embedded rowids");
duckdb_conn.execute_batch(&format!(
"UPDATE dl.main.{TABLE} SET name = name || '_v2' WHERE id IN (1, 3);"
))?;
all_passed &= compare_step("After UPDATE", duckdb_conn, runtime, cfg).await?;
header("Step 3: DELETE id=4");
duckdb_conn.execute_batch(&format!("DELETE FROM dl.main.{TABLE} WHERE id = 4;"))?;
all_passed &= compare_step("After DELETE id=4", duckdb_conn, runtime, cfg).await?;
header("Step 4: DELETE all rows");
duckdb_conn.execute_batch(&format!("DELETE FROM dl.main.{TABLE};"))?;
all_passed &= compare_step("After DELETE all", duckdb_conn, runtime, cfg).await?;
if all_passed {
Ok(())
} else {
Err("at least one lifecycle step mismatched".into())
}
}
fn drop_table(conn: &duckdb::Connection) -> Result<(), Box<dyn Error>> {
conn.execute_batch(&format!("DROP TABLE IF EXISTS dl.main.{TABLE};"))?;
Ok(())
}
async fn compare_step(
label: &str,
duckdb_conn: &duckdb::Connection,
runtime: &Arc<RuntimeEnv>,
cfg: &Config,
) -> Result<bool, Box<dyn Error>> {
println!("[{label}]");
let duckdb_rows = duckdb_read(duckdb_conn, QUERY)?;
println!(" DuckDB: {} rows", duckdb_rows.len());
print_rows("DuckDB", &duckdb_rows);
let datafusion_rows = datafusion_read(runtime, cfg, QUERY).await?;
println!(" DataFusion: {} rows", datafusion_rows.len());
print_rows("DataFusion", &datafusion_rows);
if duckdb_rows == datafusion_rows {
println!(" ✅ MATCH");
Ok(true)
} else {
println!(" ❌ MISMATCH");
if duckdb_rows.len() != datafusion_rows.len() {
println!(
" row count differs: DuckDB={} vs DataFusion={}",
duckdb_rows.len(),
datafusion_rows.len()
);
}
for (i, (a, b)) in duckdb_rows.iter().zip(datafusion_rows.iter()).enumerate() {
if a != b {
println!(" diff at row {i}: DuckDB={a:?} DataFusion={b:?}");
}
}
Ok(false)
}
}
fn setup_duckdb(cfg: &Config) -> Result<duckdb::Connection, Box<dyn Error>> {
let conn = duckdb::Connection::open_in_memory()?;
conn.execute_batch(
"INSTALL ducklake; LOAD ducklake;
INSTALL httpfs; LOAD httpfs;
INSTALL postgres; LOAD postgres;",
)?;
conn.execute_batch(&format!(
"CREATE OR REPLACE SECRET tigris (
TYPE S3,
KEY_ID '{key}',
SECRET '{secret}',
REGION 'auto',
ENDPOINT '{endpoint_host}',
URL_STYLE 'path',
SCOPE 's3://{bucket}'
);",
key = cfg.s3_key,
secret = cfg.s3_secret,
endpoint_host = cfg.s3_endpoint_host,
bucket = cfg.s3_bucket,
))?;
conn.execute_batch(&format!(
"ATTACH 'ducklake:postgres:{pg}' AS dl (DATA_PATH 's3://{bucket}/{data_path}');",
pg = cfg.pg_conn,
bucket = cfg.s3_bucket,
data_path = cfg.data_path,
))?;
println!("✓ DuckDB attached to ducklake catalog");
Ok(conn)
}
fn setup_runtime(cfg: &Config) -> Result<Arc<RuntimeEnv>, Box<dyn Error>> {
let runtime = Arc::new(RuntimeEnv::default());
let s3: Arc<dyn ObjectStore> = Arc::new(
AmazonS3Builder::new()
.with_endpoint(&cfg.s3_endpoint)
.with_bucket_name(&cfg.s3_bucket)
.with_access_key_id(&cfg.s3_key)
.with_secret_access_key(&cfg.s3_secret)
.with_region("auto")
.build()?,
);
runtime.register_object_store(&Url::parse(&format!("s3://{}/", cfg.s3_bucket))?, s3);
println!("✓ S3 object store registered");
Ok(runtime)
}
fn duckdb_read(conn: &duckdb::Connection, sql: &str) -> Result<Vec<Row>, Box<dyn Error>> {
let mut stmt = conn.prepare(sql)?;
let mut rows = stmt.query([])?;
let mut out = Vec::new();
while let Some(r) = rows.next()? {
let rowid: i64 = r.get(0)?;
let id: i64 = if let Ok(v) = r.get::<_, Option<i32>>(1) {
v.unwrap_or(0) as i64
} else {
r.get::<_, Option<i64>>(1)?.unwrap_or(0)
};
let name: Option<String> = r.get(2)?;
out.push(Row {
rowid,
id,
name,
});
}
Ok(out)
}
async fn datafusion_read(
runtime: &Arc<RuntimeEnv>,
cfg: &Config,
sql: &str,
) -> Result<Vec<Row>, Box<dyn Error>> {
let provider = PostgresMetadataProvider::new(&cfg.pg_uri).await?;
let catalog = DuckLakeCatalog::new(provider)?.with_row_lineage(true);
let cfg = SessionConfig::new().with_default_catalog_and_schema("dl", "main");
let ctx = SessionContext::new_with_config_rt(cfg, runtime.clone());
ctx.register_catalog("dl", Arc::new(catalog));
let df = ctx.sql(sql).await?;
let batches = df.collect().await?;
rows_from_batches(&batches)
}
fn rows_from_batches(batches: &[RecordBatch]) -> Result<Vec<Row>, Box<dyn Error>> {
let mut out = Vec::new();
for batch in batches {
let rowids = batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or("rowid column should be Int64")?;
let id_col = batch.column(1);
let name_col = batch.column(2);
for i in 0..batch.num_rows() {
if rowids.is_null(i) {
return Err("got NULL rowid from DataFusion".into());
}
let rowid = rowids.value(i);
let id: i64 = if let Some(arr) = id_col.as_any().downcast_ref::<Int32Array>() {
if arr.is_null(i) {
0
} else {
arr.value(i) as i64
}
} else if let Some(arr) = id_col.as_any().downcast_ref::<Int64Array>() {
if arr.is_null(i) {
0
} else {
arr.value(i)
}
} else {
return Err(format!("unexpected id type {:?}", id_col.data_type()).into());
};
let name = if let Some(arr) = name_col.as_any().downcast_ref::<StringArray>() {
if arr.is_null(i) {
None
} else {
Some(arr.value(i).to_string())
}
} else if let Some(arr) = name_col.as_any().downcast_ref::<StringViewArray>() {
if arr.is_null(i) {
None
} else {
Some(arr.value(i).to_string())
}
} else {
return Err(format!("unexpected name type {:?}", name_col.data_type()).into());
};
out.push(Row {
rowid,
id,
name,
});
}
}
Ok(out)
}
fn print_rows(label: &str, rows: &[Row]) {
for r in rows {
println!(
" [{label:>10}] rowid={:>4} id={:>4} name={:?}",
r.rowid, r.id, r.name
);
}
if rows.is_empty() {
println!(" [{label:>10}] (empty)");
}
}