#![cfg(feature = "native")]
use knut_thund::backend::{native::NativeBackend, ExecBackend, RunHandle};
use knut_thund::ir::{
CdcSpec, Dataset, Expectation, Flow, FlowKind, OnViolation, OutputMode, OutputType, Pipeline,
ScdType, SourceSpec, Trigger,
};
use datafusion::arrow::array::{Array, Int64Array, RecordBatch, StringArray};
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use std::sync::Arc;
fn batch(ids: &[i64], vs: &[i64]) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("v", DataType::Int64, false),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(ids.to_vec())),
Arc::new(Int64Array::from(vs.to_vec())),
],
)
.unwrap()
}
fn micro_batches() -> Vec<Vec<RecordBatch>> {
vec![
vec![batch(&[1, 2], &[10, -5])],
vec![batch(&[3, 4], &[20, 0])],
vec![batch(&[5], &[30])],
]
}
fn all_rows() -> RecordBatch {
batch(&[1, 2, 3, 4, 5], &[10, -5, 20, 0, 30])
}
fn stream_pipeline(query: &str, output_mode: OutputMode, trigger: Trigger) -> Pipeline {
let mut flow = Flow::streaming(
"f_events",
"events_out",
SourceSpec::Kafka {
bootstrap: "localhost:9092".into(),
topic: "events".into(),
format: "json".into(),
},
)
.with_query(query);
if let FlowKind::Streaming {
output_mode: om,
trigger: tr,
..
} = &mut flow.kind
{
*om = output_mode;
*tr = trigger;
}
Pipeline::new("stream")
.with_dataset(Dataset::new("events_out", OutputType::Table).incremental())
.with_flow(flow)
}
fn batch_result(query: &str) -> Vec<i64> {
let p = Pipeline::new("batch")
.with_dataset(Dataset::new("events_out", OutputType::MaterializedView))
.with_flow(Flow::batch("f_batch", "events_out", ["events"]).with_query(query));
let run = NativeBackend::new()
.with_input("events", vec![all_rows()])
.run(&p)
.expect("batch runs");
sorted_ids(run.output("events_out").expect("batch output"))
}
fn sorted_ids(batches: &[RecordBatch]) -> Vec<i64> {
let mut v: Vec<i64> = batches
.iter()
.flat_map(|b| {
b.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
v.sort_unstable();
v
}
#[test]
fn append_mode_emits_new_rows_per_trigger_and_matches_batch() {
let query = "SELECT id, v FROM events WHERE v > 0";
let p = stream_pipeline(query, OutputMode::Append, Trigger::Continuous);
let run = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.expect("streaming run completes");
assert_eq!(run.trigger_count("events_out"), 3, "one increment per micro-batch");
let incs = run.increments("events_out");
assert_eq!(sorted_ids(&incs[0]), vec![1]);
assert_eq!(sorted_ids(&incs[1]), vec![3]);
assert_eq!(sorted_ids(&incs[2]), vec![5]);
let concat: Vec<i64> = {
let mut all: Vec<i64> = incs.iter().flat_map(|i| sorted_ids(i)).collect();
all.sort_unstable();
all
};
let final_rows = sorted_ids(run.output("events_out").expect("final output"));
assert_eq!(concat, final_rows, "increments reconstruct the final table");
assert_eq!(final_rows, batch_result(query), "final == batch path over same data");
}
#[test]
fn complete_mode_emits_full_aggregate_each_trigger_and_matches_batch() {
let query = "SELECT count(*) AS n, sum(v) AS total FROM events";
let p = stream_pipeline(query, OutputMode::Complete, Trigger::Continuous);
let run = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.expect("streaming run completes");
assert_eq!(run.trigger_count("events_out"), 3, "one increment per micro-batch");
let incs = run.increments("events_out");
for inc in incs {
let rows: usize = inc.iter().map(|b| b.num_rows()).sum();
assert_eq!(rows, 1, "complete-mode aggregate emits one row per trigger");
}
let n = |inc: &Vec<RecordBatch>| {
inc[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap().value(0)
};
assert_eq!(n(&incs[0]), 2);
assert_eq!(n(&incs[1]), 4);
assert_eq!(n(&incs[2]), 5);
let out = run.output("events_out").expect("final output");
let total = out[0].column(1).as_any().downcast_ref::<Int64Array>().unwrap().value(0);
assert_eq!((n(&out.to_vec()), total), (5, 55));
}
#[test]
fn available_now_is_a_single_bounded_pass() {
let query = "SELECT id, v FROM events WHERE v > 0";
let p = stream_pipeline(query, OutputMode::Append, Trigger::AvailableNow);
let run = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.expect("streaming run completes");
assert_eq!(run.trigger_count("events_out"), 1, "AvailableNow => single pass");
let final_rows = sorted_ids(run.output("events_out").expect("final output"));
assert_eq!(final_rows, batch_result(query), "single pass still equals batch path");
assert_eq!(sorted_ids(&run.increments("events_out")[0]), vec![1, 3, 5]);
}
#[test]
fn drop_expectation_filters_streaming_result() {
let query = "SELECT id, v FROM events";
let mut p = stream_pipeline(query, OutputMode::Append, Trigger::AvailableNow);
p.flows[0]
.expectations
.push(Expectation::new("v_positive", "v > 0").on(OnViolation::Drop));
let mut run = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.expect("streaming run completes");
assert_eq!(sorted_ids(run.output("events_out").expect("final output")), vec![1, 3, 5]);
assert!(run
.poll_events()
.unwrap()
.iter()
.any(|e| e.message.contains("DROP")));
}
#[test]
fn fail_expectation_aborts_streaming_run() {
let query = "SELECT id, v FROM events";
let mut p = stream_pipeline(query, OutputMode::Append, Trigger::AvailableNow);
p.flows[0]
.expectations
.push(Expectation::new("v_positive", "v > 0").on(OnViolation::Fail));
let err = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.unwrap_err();
assert!(matches!(err, knut_thund::ThundError::Backend(m) if m.contains("FAILED")));
}
#[test]
fn downstream_batch_flow_reads_streaming_result() {
let mut flow = Flow::streaming(
"f_events",
"events_out",
SourceSpec::Kafka {
bootstrap: "localhost:9092".into(),
topic: "events".into(),
format: "json".into(),
},
)
.with_query("SELECT id, v FROM events WHERE v > 0");
if let FlowKind::Streaming { trigger, .. } = &mut flow.kind {
*trigger = Trigger::AvailableNow;
}
let p = Pipeline::new("stream_then_batch")
.with_dataset(Dataset::new("events_out", OutputType::Table).incremental())
.with_dataset(Dataset::new("summary", OutputType::MaterializedView))
.with_flow(flow)
.with_flow(
Flow::batch("f_sum", "summary", ["events_out"])
.with_query("SELECT count(*) AS n, sum(v) AS total FROM events_out"),
);
let run = NativeBackend::new()
.with_stream_input("events", micro_batches())
.run(&p)
.expect("mixed streaming+batch run completes");
let out = run.output("summary").expect("summary produced");
let n = out[0].column(0).as_any().downcast_ref::<Int64Array>().unwrap().value(0);
let total = out[0].column(1).as_any().downcast_ref::<Int64Array>().unwrap().value(0);
assert_eq!((n, total), (3, 60));
}
#[test]
fn unseeded_kafka_flow_still_defers() {
let p = stream_pipeline("SELECT * FROM events", OutputMode::Append, Trigger::Continuous);
let mut run = NativeBackend::new().run(&p).expect("run completes despite deferral");
assert_eq!(run.row_count("events_out"), 0, "nothing materialised for an unseeded live source");
assert!(run.poll_events().unwrap().iter().any(|e| e.message.contains("deferred")));
}
fn change_batch(rows: &[(i64, &str, i64, &str)]) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
Field::new("seq", DataType::Int64, false),
Field::new("_change_type", DataType::Utf8, false),
]));
let ids: Vec<i64> = rows.iter().map(|r| r.0).collect();
let names: Vec<&str> = rows.iter().map(|r| r.1).collect();
let seqs: Vec<i64> = rows.iter().map(|r| r.2).collect();
let cts: Vec<&str> = rows.iter().map(|r| r.3).collect();
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(ids)),
Arc::new(StringArray::from(names)),
Arc::new(Int64Array::from(seqs)),
Arc::new(StringArray::from(cts)),
],
)
.unwrap()
}
fn cdc_micro_batches() -> Vec<Vec<RecordBatch>> {
vec![
vec![change_batch(&[(1, "a", 1, "INSERT"), (2, "b", 2, "INSERT")])],
vec![change_batch(&[(1, "a2", 3, "UPDATE_AFTER"), (3, "c", 4, "INSERT")])],
vec![change_batch(&[(2, "b", 5, "DELETE")])],
]
}
fn cdc_pipeline(scd_type: ScdType) -> Pipeline {
let flow = Flow {
name: "f_cdc".into(),
target: "dim".into(),
reads: vec!["changes".into()],
kind: FlowKind::Cdc {
cdc: CdcSpec {
keys: vec!["id".into()],
sequence_by: "seq".into(),
apply_as_deletes: None,
scd_type,
},
},
query: Some("SELECT * FROM changes".into()),
expectations: Vec::new(),
};
Pipeline::new("cdc")
.with_dataset(Dataset::new("dim", OutputType::Table).incremental())
.with_flow(flow)
}
fn i64_col(batches: &[RecordBatch], name: &str) -> Vec<i64> {
batches
.iter()
.flat_map(|b| {
let idx = b.schema().index_of(name).expect("column present");
b.column(idx)
.as_any()
.downcast_ref::<Int64Array>()
.expect("i64 column")
.values()
.to_vec()
})
.collect()
}
fn id_name_pairs(batches: &[RecordBatch]) -> Vec<(i64, String)> {
let mut out = Vec::new();
for b in batches {
let ids = b.column(b.schema().index_of("id").unwrap()).as_any().downcast_ref::<Int64Array>().unwrap();
let names = b.column(b.schema().index_of("name").unwrap()).as_any().downcast_ref::<StringArray>().unwrap();
for r in 0..b.num_rows() {
out.push((ids.value(r), names.value(r).to_string()));
}
}
out.sort();
out
}
#[test]
fn cdc_scd_type1_upserts_and_deletes() {
let run = NativeBackend::new()
.with_stream_input("changes", cdc_micro_batches())
.run(&cdc_pipeline(ScdType::Type1))
.expect("cdc run completes");
let out = run.output("dim").expect("merged target produced");
assert_eq!(
id_name_pairs(out),
vec![(1, "a2".to_string()), (3, "c".to_string())],
"latest non-deleted row per key"
);
assert_eq!(run.trigger_count("dim"), 3, "one merge per changelog micro-batch");
let incs = run.increments("dim");
let mut t0 = i64_col(&incs[0], "id");
t0.sort_unstable();
assert_eq!(t0, vec![1, 2]);
let mut t2 = i64_col(&incs[2], "id");
t2.sort_unstable();
assert_eq!(t2, vec![1, 3], "the DELETE removed id=2 from the target");
}
#[test]
fn cdc_scd_type2_keeps_version_history() {
let run = NativeBackend::new()
.with_stream_input("changes", cdc_micro_batches())
.run(&cdc_pipeline(ScdType::Type2))
.expect("cdc run completes");
let out = run.output("dim").expect("merged target produced");
let out: Vec<RecordBatch> = out.to_vec();
let total: usize = out.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 4, "one row per non-delete change (history preserved)");
let mut versions: Vec<(i64, String, i64, Option<i64>)> = Vec::new();
for b in &out {
let ids = b.column(b.schema().index_of("id").unwrap()).as_any().downcast_ref::<Int64Array>().unwrap();
let names = b.column(b.schema().index_of("name").unwrap()).as_any().downcast_ref::<StringArray>().unwrap();
let starts = b.column(b.schema().index_of("__start_at").unwrap()).as_any().downcast_ref::<Int64Array>().unwrap();
let ends = b.column(b.schema().index_of("__end_at").unwrap()).as_any().downcast_ref::<Int64Array>().unwrap();
for r in 0..b.num_rows() {
let end = if ends.is_null(r) { None } else { Some(ends.value(r)) };
versions.push((ids.value(r), names.value(r).to_string(), starts.value(r), end));
}
}
versions.sort();
assert!(versions.contains(&(1, "a".into(), 1, Some(3))), "id=1 first version closed at seq 3");
assert!(versions.contains(&(1, "a2".into(), 3, None)), "id=1 current version open");
assert!(versions.contains(&(2, "b".into(), 2, Some(5))), "id=2 closed by its delete");
assert!(!versions.iter().any(|v| v.0 == 2 && v.3.is_none()), "id=2 has no open version after delete");
assert!(versions.contains(&(3, "c".into(), 4, None)), "id=3 current version open");
}
#[test]
fn cdc_flow_without_a_changelog_errors() {
let err = NativeBackend::new()
.run(&cdc_pipeline(ScdType::Type1))
.unwrap_err();
assert!(
matches!(err, knut_thund::ThundError::Backend(_)),
"unseeded CDC changelog fails as a backend error, got {err:?}"
);
}