pub const DEMO_CATALOG: &str = "lake";
pub const DEMO_NAMESPACE: &str = "knut_demo";
pub const DEMO_TABLE: &str = "people";
pub const DEMO_ROWS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IcebergDemoPlan {
pub url: String,
pub namespace: String,
pub table: String,
pub rows: usize,
pub statements: Vec<String>,
pub verify_count: String,
pub verify_snapshots: String,
}
impl IcebergDemoPlan {
pub fn program(&self) -> Vec<&str> {
self.statements
.iter()
.map(String::as_str)
.chain([self.verify_count.as_str(), self.verify_snapshots.as_str()])
.collect()
}
}
pub fn iceberg_demo_plan(url: &str) -> IcebergDemoPlan {
iceberg_demo_plan_for(url, DEMO_CATALOG, DEMO_NAMESPACE, DEMO_TABLE, DEMO_ROWS)
}
pub fn iceberg_demo_plan_for(
url: &str,
catalog: &str,
namespace: &str,
table: &str,
rows: usize,
) -> IcebergDemoPlan {
let ns = format!("{catalog}.{namespace}");
let tbl = format!("{ns}.{table}");
let values = (0..rows)
.map(|i| format!("({i}, 'person_{i}', {})", 20 + i))
.collect::<Vec<_>>()
.join(", ");
IcebergDemoPlan {
url: url.to_string(),
namespace: ns.clone(),
table: tbl.clone(),
rows,
statements: vec![
format!("DROP TABLE IF EXISTS {tbl}"),
format!("CREATE NAMESPACE IF NOT EXISTS {ns}"),
format!("CREATE TABLE {tbl} (id BIGINT, name STRING, age BIGINT) USING iceberg"),
format!("INSERT INTO {tbl} VALUES {values}"),
],
verify_count: format!("SELECT count(*) AS n FROM {tbl}"),
verify_snapshots: format!("SELECT snapshot_id FROM {tbl}.snapshots"),
}
}
pub const DEMO_FEATURE: &str = "spark41";
pub const fn spark_client_linked() -> bool {
cfg!(feature = "spark")
}
pub fn feature_missing_error(table: &str) -> String {
format!(
"the knut -> Spark 4.1 + Iceberg load needs --features {DEMO_FEATURE} \
(target {table}); this build linked no Spark Connect client, so it cannot \
run the load at all"
)
}
#[cfg(feature = "spark")]
pub async fn run_iceberg_demo(plan: &IcebergDemoPlan) -> crate::error::Result<i64> {
use arrow_array::{Array, Int64Array};
use crate::error::BifrostError;
use crate::source::SparkConnectSource;
let source = SparkConnectSource::connect(&plan.url).await?;
for stmt in &plan.statements {
source.sql(stmt).await?;
}
let batches = source.sql(&plan.verify_count).await?;
let count = batches
.iter()
.find(|b| b.num_rows() > 0 && b.num_columns() > 0)
.and_then(|b| {
b.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.map(|a| a.value(0))
})
.ok_or_else(|| {
BifrostError::Source(format!("no count came back from {}", plan.verify_count))
})?;
if count != plan.rows as i64 {
return Err(BifrostError::Source(format!(
"{} landed {count} rows, expected {}",
plan.table, plan.rows
)));
}
let snaps = source.sql(&plan.verify_snapshots).await?;
if !snaps.iter().any(|b| b.num_rows() > 0) {
return Err(BifrostError::Source(format!(
"{} committed no Iceberg snapshot — is the Iceberg runtime loaded?",
plan.table
)));
}
Ok(count)
}
#[cfg(not(feature = "spark"))]
pub async fn run_iceberg_demo(plan: &IcebergDemoPlan) -> crate::error::Result<i64> {
Err(crate::error::BifrostError::Source(feature_missing_error(
&plan.table,
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn demo_plan_is_the_ordered_iceberg_program() {
let plan = iceberg_demo_plan("sc://localhost:15012");
assert_eq!(plan.url, "sc://localhost:15012");
assert_eq!(plan.namespace, "lake.knut_demo");
assert_eq!(plan.table, "lake.knut_demo.people");
assert_eq!(plan.rows, DEMO_ROWS);
assert_eq!(plan.statements.len(), 4);
assert!(
plan.statements[0].starts_with("DROP TABLE IF EXISTS"),
"idempotent re-run"
);
assert!(plan.statements[1].starts_with("CREATE NAMESPACE IF NOT EXISTS"));
assert!(
plan.statements[2].contains("USING iceberg"),
"the table is ICEBERG, not parquet"
);
assert!(plan.statements[3].starts_with(&format!("INSERT INTO {}", plan.table)));
assert_eq!(
plan.verify_snapshots,
format!("SELECT snapshot_id FROM {}.snapshots", plan.table)
);
assert!(plan.verify_count.contains("count(*)"));
assert_eq!(plan.program().len(), 6, "4 statements + 2 verifications");
}
#[test]
fn demo_insert_is_deterministic_and_exact() {
let plan = iceberg_demo_plan("sc://localhost:15012");
let insert = &plan.statements[3];
assert_eq!(insert.matches("person_").count(), DEMO_ROWS);
assert!(insert.contains("(0, 'person_0', 20)"));
let last = DEMO_ROWS - 1;
assert!(insert.contains(&format!("({last}, 'person_{last}', {})", 20 + last)));
assert_eq!(iceberg_demo_plan("sc://localhost:15012"), plan);
}
#[test]
fn feature_missing_error_names_the_feature_and_the_table() {
let plan = iceberg_demo_plan("sc://localhost:15012");
let msg = feature_missing_error(&plan.table);
assert!(
msg.contains("--features spark41"),
"names the feature: {msg}"
);
assert!(
msg.contains("lake.knut_demo.people"),
"names the target table: {msg}"
);
assert_eq!(DEMO_FEATURE, "spark41");
}
#[test]
fn run_iceberg_demo_exists_in_every_build_and_fails_loud_without_the_client() {
assert_eq!(spark_client_linked(), cfg!(feature = "spark"));
#[cfg(not(feature = "spark"))]
{
let plan = iceberg_demo_plan("sc://localhost:15012");
let err = tokio::runtime::Builder::new_current_thread()
.build()
.expect("current-thread runtime")
.block_on(run_iceberg_demo(&plan))
.expect_err("a build with no Spark client CANNOT have landed rows");
let msg = err.to_string();
assert!(
msg.contains("--features spark41"),
"loud about the feature: {msg}"
);
assert!(
msg.contains("lake.knut_demo.people"),
"loud about the table: {msg}"
);
}
}
#[test]
fn demo_plan_knobs_scope_catalog_namespace_and_rows() {
let plan = iceberg_demo_plan_for("sc://box:9999", "lake", "zone_test", "boats", 3);
assert_eq!(plan.table, "lake.zone_test.boats");
assert_eq!(plan.rows, 3);
assert_eq!(plan.statements[3].matches("person_").count(), 3);
assert_ne!(plan.table, iceberg_demo_plan("sc://box:9999").table);
}
}