use std::sync::{Arc, Mutex};
use arrow_array::{Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use async_trait::async_trait;
use knut_bifrost::mapping::SyncMode;
use knut_bifrost::source::{ArrowBatches, ArrowSource, EmptyRead, TableSource, TableSpec};
use knut_bifrost::state::TableState;
use knut_bifrost::{
ArrowTables, GraphMutation, Listener, MappingSpec, SinkReport, SyncState, TransformRegistry,
build_mutation, build_mutation_reported, sink::GraphSink,
};
const MAPPING: &str = r#"
version: 1
tables:
- table: lake.hr.people
mode: full
pipeline:
- vertex:
label: Person
id_column: id
properties: [name]
"#;
fn people(ids: std::ops::RangeInclusive<i64>) -> RecordBatch {
let ids: Vec<i64> = ids.collect();
let names: Vec<String> = ids.iter().map(|i| format!("person_{i}")).collect();
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(ids)),
Arc::new(StringArray::from(names)),
],
)
.expect("people batch")
}
#[derive(Clone, Default)]
struct RecordingSink {
vertices: Arc<Mutex<usize>>,
labels: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl GraphSink for RecordingSink {
async fn apply(&mut self, mutation: &GraphMutation) -> knut_bifrost::Result<SinkReport> {
let v: usize = mutation.vertices.iter().map(|s| s.batch.num_rows()).sum();
*self.vertices.lock().unwrap() += v;
self.labels
.lock()
.unwrap()
.extend(mutation.vertices.iter().map(|s| s.label.clone()));
Ok(SinkReport {
vertices_written: v,
edges_written: mutation.edges.iter().map(|s| s.batch.num_rows()).sum(),
deleted: mutation.deletes.iter().map(|s| s.batch.num_rows()).sum(),
})
}
}
fn listener_over(source: ArrowSource, sink: RecordingSink, dir: &std::path::Path) -> Listener<ArrowSource, RecordingSink> {
Listener::new(
MappingSpec::from_yaml(MAPPING).expect("mapping"),
TransformRegistry::new(),
source,
sink,
SyncState::open(dir.join("state.json")).expect("state"),
)
}
#[tokio::test]
async fn arrow_batches_drive_the_live_listener() {
const N: i64 = 137; let sink = RecordingSink::default();
let dir = tempfile::tempdir().unwrap();
let source = ArrowSource::from_batches("in-memory", "lake.hr.people", vec![people(1..=N)]);
assert_eq!(source.producer(), "in-memory");
assert_eq!(source.empty_policy(), EmptyRead::Refuse, "refusing is the default");
let mut listener = listener_over(source, sink.clone(), dir.path());
let round = listener.sync_once().await.expect("a round over Arrow batches");
assert_eq!(round.failed, vec![], "no sync failures");
assert_eq!(round.synced.len(), 1, "the one mapped table synced");
assert_eq!(round.synced[0].0, "lake.hr.people");
assert_eq!(round.synced[0].1, N as usize, "every injected row shipped");
assert_eq!(
*sink.vertices.lock().unwrap(),
N as usize,
"the sink received one vertex row per input row"
);
assert_eq!(sink.labels.lock().unwrap().as_slice(), ["Person"]);
}
#[tokio::test]
async fn full_mode_re_reads_every_round() {
let sink = RecordingSink::default();
let dir = tempfile::tempdir().unwrap();
let source = ArrowSource::from_batches("in-memory", "lake.hr.people", vec![people(1..=10)]);
let mut listener = listener_over(source, sink.clone(), dir.path());
listener.sync_once().await.expect("round 1");
listener.sync_once().await.expect("round 2");
assert_eq!(
*sink.vertices.lock().unwrap(),
20,
"full mode ships the same 10 rows twice; the sink's MERGE makes that safe"
);
}
#[tokio::test]
async fn an_empty_arrow_read_is_refused_not_a_built_graph() {
let spec = TableSpec::new("lake.hr.people", SyncMode::Full);
let fresh = TableState::default();
let none = ArrowSource::from_batches("skade-remote", "lake.hr.people", vec![]);
let e = none
.fetch_new(&spec, &fresh)
.await
.expect_err("a zero-batch read must not look like a successful empty build");
let msg = e.to_string();
assert!(msg.contains("lake.hr.people"), "the refusal names the table: {msg}");
assert!(msg.contains("skade-remote"), "and the producer: {msg}");
assert!(msg.contains("0 rows"), "and what it actually got: {msg}");
let empty_rows =
ArrowSource::from_batches("iceberg-rest", "lake.hr.people", vec![people(1..=0)]);
let e = empty_rows
.fetch_new(&spec, &fresh)
.await
.expect_err("a zero-ROW batch is still an empty read");
assert!(e.to_string().contains("1 batch(es) and 0 rows"), "{e}");
let wrong = ArrowSource::new(
"skade-remote",
ArrowTables::new().with_table("lake.hr.People", vec![people(1..=3)]),
);
let e = wrong
.fetch_new(&spec, &fresh)
.await
.expect_err("a table the producer does not hold is not an empty table");
assert!(
e.to_string().contains("lake.hr.People"),
"the refusal lists the tables the producer holds: {e}"
);
let allowed = ArrowSource::from_batches("skade-remote", "lake.hr.people", vec![])
.allowing_empty();
assert_eq!(allowed.empty_policy(), EmptyRead::Allow);
assert!(
allowed.fetch_new(&spec, &fresh).await.unwrap().is_none(),
"a declared-empty table is Ok(None) — nothing to build, and nobody claimed there was"
);
}
#[test]
fn an_empty_offline_build_is_reported_not_silently_ok() {
let spec = MappingSpec::from_yaml(MAPPING).unwrap();
let wrong = ArrowTables::new().with_table("lake.hr.persons", vec![people(1..=50)]);
let mutation = build_mutation(&spec, &wrong).expect("the old API still returns Ok");
assert!(
mutation.vertices.is_empty(),
"this is the shape the gate exists for: Ok, and an empty graph"
);
let (_, report) = build_mutation_reported(&spec, &wrong).unwrap();
assert_eq!(report.tables_mapped, Vec::<String>::new());
assert_eq!(report.tables_empty, vec!["lake.hr.people".to_string()]);
assert_eq!((report.rows_read, report.vertices, report.edges), (0, 0, 0));
let e = report
.require_non_empty()
.expect_err("an empty build must be refusable in one line");
assert!(e.to_string().contains("lake.hr.people"), "{e}");
assert!(e.to_string().contains("EMPTY graph"), "{e}");
let right = ArrowTables::new().with_table("lake.hr.people", vec![people(1..=50)]);
let (m, report) = build_mutation_reported(&spec, &right).unwrap();
assert_eq!(report.tables_mapped, vec!["lake.hr.people".to_string()]);
assert_eq!(report.tables_empty, Vec::<String>::new());
assert_eq!(report.rows_read, 50);
assert_eq!(report.vertices, 50);
assert_eq!(m.vertices[0].label, "Person");
report.require_non_empty().expect("a real build passes");
}
#[tokio::test]
async fn cdc_over_an_arrow_source_is_refused_naming_why() {
let source = ArrowSource::from_batches("iceberg-rest", "lake.hr.people", vec![people(1..=5)]);
let e = source
.fetch_new(
&TableSpec::new("lake.hr.people", SyncMode::Cdc),
&TableState::default(),
)
.await
.expect_err("an Arrow batch source has no change feed");
let msg = e.to_string();
assert!(msg.contains("no change feed"), "{msg}");
assert!(msg.contains("SparkIcebergCdc"), "it names what can: {msg}");
assert!(msg.contains("IcebergSource"), "and the pure-Rust one: {msg}");
assert!(source.cdc_capability("lake.hr.people").is_none());
let spec = TableSpec::new("lake.hr.people", SyncMode::Incremental);
let first = source.fetch_new(&spec, &TableState::default()).await.unwrap();
assert!(first.is_some(), "the first incremental round ships the batches");
let synced = TableState {
synced_at: Some("2026-09-01T00:00:00Z".into()),
..Default::default()
};
assert!(
source.fetch_new(&spec, &synced).await.unwrap().is_none(),
"a second incremental round has nothing new — a batch source is not a stream"
);
}
#[cfg(feature = "skade")]
#[tokio::test]
async fn a_skade_warehouse_feeds_bifrost_through_the_arrow_source() {
const N: i64 = 91;
let wh_dir = tempfile::tempdir().unwrap();
{
let wh = skade::Warehouse::open(wh_dir.path()).await.expect("open warehouse");
let batch = people(1..=N);
let mut table = wh
.table_or_create("people", &batch.schema())
.await
.expect("create table");
table.append(&[batch]).await.expect("append");
}
let batches = {
let wh = skade::Warehouse::open(wh_dir.path()).await.expect("reopen");
let table = wh.table("people").await.expect("table handle");
table.read().await.expect("read the table as Arrow")
};
let rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
assert_eq!(rows, N as usize, "skade handed back every seeded row");
let sink = RecordingSink::default();
let dir = tempfile::tempdir().unwrap();
let source = ArrowSource::new(
"skade",
ArrowTables::new().with_table("lake.hr.people", batches),
);
let mut listener = listener_over(source, sink.clone(), dir.path());
let round = listener.sync_once().await.expect("a round over a skade warehouse");
assert_eq!(round.failed, vec![], "no sync failures");
assert_eq!(round.synced.len(), 1);
assert_eq!(round.synced[0].1, N as usize, "every skade row reached the mapping");
assert_eq!(
*sink.vertices.lock().unwrap(),
N as usize,
"a graph was built from a NON-Spark warehouse: {N} Person vertices"
);
assert_eq!(sink.labels.lock().unwrap().as_slice(), ["Person"]);
let empty_dir = tempfile::tempdir().unwrap();
let empty_batches = {
let wh = skade::Warehouse::open(empty_dir.path()).await.expect("open");
let schema = people(1..=0).schema();
let table = wh.table_or_create("people", &schema).await.expect("create");
table.read().await.expect("read an empty table")
};
assert_eq!(
empty_batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
0,
"the second warehouse really is empty"
);
let empty_sink = RecordingSink::default();
let empty_state = tempfile::tempdir().unwrap();
let mut empty_listener = listener_over(
ArrowSource::new(
"skade",
ArrowTables::new().with_table("lake.hr.people", empty_batches),
),
empty_sink.clone(),
empty_state.path(),
);
let round = empty_listener.sync_once().await.expect("the round itself returns");
assert_eq!(
round.synced,
vec![],
"an empty warehouse must not report a synced table"
);
assert_eq!(round.failed.len(), 1, "it reports a FAILURE, not a silent success");
assert!(
round.failed[0].1.contains("0 rows"),
"and the failure says what happened: {}",
round.failed[0].1
);
assert_eq!(
*empty_sink.vertices.lock().unwrap(),
0,
"nothing was written, and nothing claimed it was"
);
}