#![cfg(feature = "native")]
use knut_thund::backend::{
ExecBackend, RunHandle,
native::{NativeBackend, StreamingMode},
};
use knut_thund::ir::{
CdcSpec, Dataset, Expectation, Flow, FlowKind, OnViolation, OutputMode, OutputType, Pipeline,
ScdType, SourceSpec, Trigger, Watermark, WindowSpec,
};
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 evt_batch(ids: &[i64], ets: &[i64]) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("et", DataType::Int64, false),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(ids.to_vec())),
Arc::new(Int64Array::from(ets.to_vec())),
],
)
.unwrap()
}
fn wm_micro_batches() -> Vec<Vec<RecordBatch>> {
vec![
vec![evt_batch(&[1, 2], &[100, 110])], vec![evt_batch(&[3, 4], &[200, 210])], vec![evt_batch(&[5, 6], &[120, 300])], ]
}
fn wm_pipeline() -> Pipeline {
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, et FROM events")
.with_watermark(Watermark {
event_time_column: "et".into(),
allowed_lateness_ms: 50,
idle_timeout_ms: 0,
});
if let FlowKind::Streaming {
output_mode,
trigger,
..
} = &mut flow.kind
{
*output_mode = OutputMode::Append;
*trigger = Trigger::Continuous;
}
Pipeline::new("wm_stream")
.with_dataset(Dataset::new("events_out", OutputType::Table).incremental())
.with_flow(flow)
}
#[test]
fn incremental_watermark_drops_and_counts_late_rows() {
let recompute = NativeBackend::new()
.with_stream_input("events", wm_micro_batches())
.run(&wm_pipeline())
.expect("recompute runs");
assert_eq!(
recompute.row_count("events_out"),
6,
"recompute keeps all rows"
);
assert_eq!(
sorted_ids(recompute.output("events_out").unwrap()),
vec![1, 2, 3, 4, 5, 6]
);
let wm_state = tempfile::tempdir().unwrap();
let mut incremental = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", wm_state.path().display()))
.with_stream_input("events", wm_micro_batches())
.run(&wm_pipeline())
.expect("incremental runs");
assert_eq!(
incremental.row_count("events_out"),
5,
"one late row dropped"
);
assert_eq!(
sorted_ids(incremental.output("events_out").unwrap()),
vec![1, 2, 3, 4, 6],
"id=5 (the late row) is gone; every on-time row survives"
);
let incs = incremental.increments("events_out");
assert_eq!(incs.len(), 3, "one increment per trigger");
assert_eq!(sorted_ids(&incs[0]), vec![1, 2]);
assert_eq!(sorted_ids(&incs[1]), vec![3, 4]);
assert_eq!(
sorted_ids(&incs[2]),
vec![6],
"id=5 dropped as late in trigger 2"
);
let events = incremental.poll_events().unwrap();
assert!(
events
.iter()
.any(|e| e.message.contains("1 late row(s) dropped")),
"the late-drop count is surfaced: {events:?}"
);
}
#[test]
fn incremental_without_late_rows_equals_recompute() {
let on_time = || {
vec![
vec![evt_batch(&[1, 2], &[100, 110])], vec![evt_batch(&[3, 4], &[150, 160])], vec![evt_batch(&[5, 6], &[200, 210])], ]
};
let recompute = NativeBackend::new()
.with_stream_input("events", on_time())
.run(&wm_pipeline())
.expect("recompute runs");
let wm_state = tempfile::tempdir().unwrap();
let incremental = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", wm_state.path().display()))
.with_stream_input("events", on_time())
.run(&wm_pipeline())
.expect("incremental runs");
assert_eq!(
sorted_ids(incremental.output("events_out").unwrap()),
sorted_ids(recompute.output("events_out").unwrap()),
"no late rows => incremental == recompute"
);
assert_eq!(incremental.row_count("events_out"), 6);
}
fn windowed_pipeline(window: WindowSpec, lateness_ms: u64) -> Pipeline {
let mut flow = Flow::streaming(
"f_events",
"win_out",
SourceSpec::Kafka {
bootstrap: "localhost:9092".into(),
topic: "events".into(),
format: "json".into(),
},
)
.with_query("SELECT COUNT(*) AS n FROM events")
.with_watermark(Watermark {
event_time_column: "et".into(),
allowed_lateness_ms: lateness_ms,
idle_timeout_ms: 0,
})
.with_window(window);
if let FlowKind::Streaming {
output_mode,
trigger,
..
} = &mut flow.kind
{
*output_mode = OutputMode::Append;
*trigger = Trigger::Continuous;
}
Pipeline::new("win_stream")
.with_dataset(Dataset::new("win_out", OutputType::Table).incremental())
.with_flow(flow)
}
fn window_counts(batches: &[RecordBatch]) -> Vec<(i64, i64, i64)> {
let mut out = Vec::new();
for b in batches {
let ws = b.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
let we = b.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
let n = b.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
for r in 0..b.num_rows() {
out.push((ws.value(r), we.value(r), n.value(r)));
}
}
out.sort_unstable();
out
}
fn run_windowed(
mb: Vec<Vec<RecordBatch>>,
window: WindowSpec,
lateness_ms: u64,
_tag: &str,
) -> knut_thund::backend::native::NativeRun {
let state = tempfile::tempdir().expect("temp state root");
NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", state.path().display()))
.with_stream_input("events", mb)
.run(&windowed_pipeline(window, lateness_ms))
.expect("windowed incremental runs")
}
#[test]
fn windowed_tumbling_closes_once_and_evicts() {
let mb = vec![
vec![evt_batch(&[1, 2], &[10, 20])], vec![evt_batch(&[3, 4], &[110, 120])], vec![evt_batch(&[5, 6], &[210, 220])], vec![evt_batch(&[7, 8], &[310, 320])], ];
let mut run = run_windowed(mb, WindowSpec::Tumbling { size_ms: 100 }, 50, "tumble");
assert_eq!(
run.windows_closed("win_out"),
4,
"four windows close exactly once"
);
assert_eq!(
window_counts(run.output("win_out").unwrap()),
vec![(0, 100, 2), (100, 200, 2), (200, 300, 2), (300, 400, 2)],
"each window aggregates exactly its own rows"
);
let events = run.poll_events().unwrap();
let msgs: Vec<&str> = events.iter().map(|e| e.message.as_str()).collect();
assert!(
msgs.iter()
.any(|m| m.contains("window [0, 100) closed & evicted at watermark 170")),
"window [0,100) closes when W first reaches 170: {msgs:?}"
);
assert!(
msgs.iter()
.any(|m| m.contains("window [100, 200) closed & evicted at watermark 270")),
"window [100,200) closes when W first reaches 270: {msgs:?}"
);
assert!(
run.used_incremental_agg("win_out"),
"COUNT(*) uses the incremental-agg operator"
);
assert_eq!(
run.peak_open_rows("win_out"),
2,
"open accumulators bounded far below 8 rows"
);
let series = run.window_open_series("win_out");
assert_eq!(
*series.last().unwrap(),
0,
"all state evicted by end-of-stream"
);
assert!(
run.peak_open_rows("win_out") < 8,
"the evicting open state stays below the total rows seen: {series:?}"
);
}
#[test]
fn windowed_late_after_close_is_dropped_not_reopened() {
let mb = vec![
vec![evt_batch(&[1, 2], &[10, 20])],
vec![evt_batch(&[3, 4], &[110, 120])],
vec![evt_batch(&[5, 6], &[210, 220])], vec![evt_batch(&[7, 8, 9], &[310, 320, 50])], ];
let mut run = run_windowed(mb, WindowSpec::Tumbling { size_ms: 100 }, 50, "late");
assert_eq!(
run.windows_closed("win_out"),
4,
"no window re-opens for a late row"
);
let counts = window_counts(run.output("win_out").unwrap());
assert_eq!(
counts,
vec![(0, 100, 2), (100, 200, 2), (200, 300, 2), (300, 400, 2)],
"the closed window's count is final; late row absorbed nowhere"
);
assert_eq!(
counts.iter().filter(|(s, _, _)| *s == 0).count(),
1,
"the closed window is emitted once, not re-emitted"
);
let events = run.poll_events().unwrap();
assert!(
events
.iter()
.any(|e| e.message.contains("1 late row(s) dropped")),
"the late arrival is counted as dropped: {:?}",
events.iter().map(|e| &e.message).collect::<Vec<_>>()
);
}
#[test]
fn windowed_hopping_closes_each_overlapping_window_once() {
let mb = vec![
vec![evt_batch(&[1], &[100])],
vec![evt_batch(&[2], &[200])],
vec![evt_batch(&[3], &[300])],
vec![evt_batch(&[4], &[400])],
];
let run = run_windowed(
mb,
WindowSpec::Hopping {
size_ms: 200,
slide_ms: 100,
},
0,
"hop",
);
assert_eq!(
run.windows_closed("win_out"),
5,
"five overlapping windows close once each"
);
assert_eq!(
window_counts(run.output("win_out").unwrap()),
vec![
(0, 200, 1), (100, 300, 2), (200, 400, 2), (300, 500, 2), (400, 600, 1), ],
"overlapping windows each aggregate their covered rows once"
);
}
#[test]
fn windowed_session_closes_on_inactivity_gap() {
let mb = vec![
vec![evt_batch(&[1], &[10])],
vec![evt_batch(&[2], &[40])],
vec![evt_batch(&[3], &[100])], vec![evt_batch(&[4], &[130])],
];
let run = run_windowed(mb, WindowSpec::Session { gap_ms: 50 }, 0, "session");
assert_eq!(
run.windows_closed("win_out"),
2,
"two sessions close once each"
);
assert_eq!(
window_counts(run.output("win_out").unwrap()),
vec![(10, 90, 2), (100, 180, 2)],
"each session aggregates its own activity span (end = last event + gap)"
);
}
fn recovery_micro_batches() -> Vec<Vec<RecordBatch>> {
vec![
vec![evt_batch(&[1, 2], &[10, 20])], vec![evt_batch(&[3, 4], &[110, 120])], vec![evt_batch(&[5, 6], &[210, 220])], vec![evt_batch(&[7, 8], &[310, 320])], ]
}
#[test]
fn checkpoint_commits_one_epoch_per_trigger() {
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let run = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri)
.with_stream_input("events", recovery_micro_batches())
.run(&windowed_pipeline(
WindowSpec::Tumbling { size_ms: 100 },
50,
))
.expect("windowed incremental runs");
assert_eq!(
run.checkpoints_committed("win_out"),
4,
"one epoch committed per trigger"
);
assert_eq!(
run.resumed_from_epoch("win_out"),
None,
"a cold run resumes from nothing"
);
assert_eq!(run.windows_closed("win_out"), 4);
}
#[test]
fn recovery_resumes_from_checkpoint_without_double_emit() {
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let win = || windowed_pipeline(WindowSpec::Tumbling { size_ms: 100 }, 50);
let oracle_state = tempfile::tempdir().unwrap();
let oracle = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", oracle_state.path().display()))
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("oracle runs");
let oracle_counts = window_counts(oracle.output("win_out").unwrap());
let run1 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_max_triggers(Some(2))
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("capped run1");
assert_eq!(
run1.checkpoints_committed("win_out"),
2,
"2 epochs committed before the crash"
);
assert_eq!(run1.windows_closed("win_out"), 0, "no window closed yet");
assert_eq!(
run1.resumed_from_epoch("win_out"),
None,
"run1 is the cold start"
);
let run2 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("resumed run2");
assert_eq!(
run2.resumed_from_epoch("win_out"),
Some(1),
"resumed from the committed epoch 1"
);
assert_eq!(
run2.windows_closed("win_out"),
4,
"all four windows close once in the resumed run"
);
assert_eq!(
window_counts(run2.output("win_out").unwrap()),
oracle_counts,
"resumed result equals a single uninterrupted run — no double-emit, no loss"
);
}
fn increment_counts(
run: &knut_thund::backend::native::NativeRun,
name: &str,
) -> Vec<(i64, i64, i64)> {
let mut out = Vec::new();
for trig in run.increments(name) {
out.extend(window_counts(trig));
}
out
}
#[test]
fn recovery_incremental_agg_window_closed_before_crash_not_reemitted() {
let win = || windowed_pipeline(WindowSpec::Tumbling { size_ms: 100 }, 50);
let oracle_state = tempfile::tempdir().unwrap();
let oracle = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", oracle_state.path().display()))
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("oracle runs");
assert!(
oracle.used_incremental_agg("win_out"),
"COUNT(*) drives the incremental-agg operator"
);
let oracle_counts = window_counts(oracle.output("win_out").unwrap());
assert_eq!(
oracle_counts,
vec![(0, 100, 2), (100, 200, 2), (200, 300, 2), (300, 400, 2)],
"the oracle closes all four windows once each"
);
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let run1 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_max_triggers(Some(3))
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("capped run1");
assert_eq!(
run1.resumed_from_epoch("win_out"),
None,
"run1 is the cold start"
);
assert_eq!(
run1.checkpoints_committed("win_out"),
3,
"3 epochs committed before the crash"
);
assert_eq!(
run1.windows_closed("win_out"),
1,
"window [0,100) closed pre-crash"
);
let run1_emitted = window_counts(run1.output("win_out").unwrap());
assert_eq!(
run1_emitted,
vec![(0, 100, 2)],
"run1 emitted exactly the pre-crash window"
);
let run2 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri)
.with_stream_input("events", recovery_micro_batches())
.run(&win())
.expect("resumed run2");
assert_eq!(
run2.resumed_from_epoch("win_out"),
Some(2),
"resumed from committed epoch 2"
);
let run2_emitted = window_counts(run2.output("win_out").unwrap());
assert!(
!run2_emitted.iter().any(|(s, _, _)| *s == 0),
"the already-closed window [0,100) is never re-emitted after recovery: {run2_emitted:?}"
);
for w in &run1_emitted {
assert!(
!run2_emitted.contains(w),
"no window emitted by BOTH runs (double-emit): {w:?}"
);
}
let mut union = run1_emitted.clone();
union.extend(run2_emitted.clone());
union.sort_unstable();
assert_eq!(
union, oracle_counts,
"run1 ∪ run2 emissions == one uninterrupted run (no loss, no dup)"
);
let run2_incr = increment_counts(&run2, "win_out");
assert!(
!run2_incr.iter().any(|(s, _, _)| *s == 0),
"run2's increment stream never re-appends the pre-crash window: {run2_incr:?}"
);
}
#[test]
fn recovery_resumes_non_windowed_watermark_path() {
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let oracle_state = tempfile::tempdir().unwrap();
let oracle = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", oracle_state.path().display()))
.with_stream_input("events", wm_micro_batches())
.run(&wm_pipeline())
.expect("oracle runs");
assert_eq!(
oracle.row_count("events_out"),
5,
"one late row dropped in the oracle"
);
let run1 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_max_triggers(Some(2))
.with_stream_input("events", wm_micro_batches())
.run(&wm_pipeline())
.expect("capped run1");
assert_eq!(run1.checkpoints_committed("events_out"), 2);
let run2 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri)
.with_stream_input("events", wm_micro_batches())
.run(&wm_pipeline())
.expect("resumed run2");
assert_eq!(
run2.resumed_from_epoch("events_out"),
Some(1),
"resumed from committed epoch 1"
);
assert_eq!(
sorted_ids(run2.output("events_out").unwrap()),
sorted_ids(oracle.output("events_out").unwrap()),
"resumed non-windowed result equals a single run (late row dropped once)"
);
}
fn evt4(rows: &[(i64, i64, i64, i64)]) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("et", DataType::Int64, false),
Field::new("g", DataType::Int64, false),
Field::new("x", DataType::Int64, false),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(
rows.iter().map(|r| r.0).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.1).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.2).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.3).collect::<Vec<_>>(),
)),
],
)
.unwrap()
}
fn ground_truth_tumbling(
rows: &[(i64, i64, i64, i64)],
size: i64,
) -> Vec<(i64, i64, i64, i64, i64, i64, i64)> {
use std::collections::BTreeMap;
let mut acc: BTreeMap<(i64, i64), (i64, i64, i64, i64)> = BTreeMap::new();
for (_, et, g, x) in rows {
let s = et.div_euclid(size) * size;
let e = acc.entry((s, *g)).or_insert((0, 0, i64::MAX, i64::MIN));
e.0 += 1;
e.1 += x;
e.2 = e.2.min(*x);
e.3 = e.3.max(*x);
}
let mut out: Vec<_> = acc
.into_iter()
.map(|((s, g), (n, sum, mn, mx))| (s, s + size, g, n, sum, mn, mx))
.collect();
out.sort_unstable();
out
}
fn agg_pipeline(query: &str) -> Pipeline {
let mut flow = Flow::streaming(
"f_events",
"win_out",
SourceSpec::Kafka {
bootstrap: "localhost:9092".into(),
topic: "events".into(),
format: "json".into(),
},
)
.with_query(query)
.with_watermark(Watermark {
event_time_column: "et".into(),
allowed_lateness_ms: 100,
idle_timeout_ms: 0,
})
.with_window(WindowSpec::Tumbling { size_ms: 100 });
if let FlowKind::Streaming {
output_mode,
trigger,
..
} = &mut flow.kind
{
*output_mode = OutputMode::Append;
*trigger = Trigger::Continuous;
}
Pipeline::new("agg_stream")
.with_dataset(Dataset::new("win_out", OutputType::Table).incremental())
.with_flow(flow)
}
fn agg_corpus() -> Vec<(i64, i64, i64, i64)> {
vec![
(1, 10, 1, 10),
(2, 20, 1, 20),
(3, 30, 2, 5),
(4, 110, 1, 7),
(5, 120, 2, 8),
(6, 210, 3, 100),
(7, 220, 1, 4),
(8, 310, 2, 50),
(9, 330, 3, 60),
]
}
fn corpus_micro_batches() -> Vec<Vec<RecordBatch>> {
let c = agg_corpus();
vec![
vec![evt4(&c[0..3])],
vec![evt4(&c[3..5])],
vec![evt4(&c[5..7])],
vec![evt4(&c[7..9])],
]
}
#[test]
fn incremental_agg_grouped_matches_ground_truth() {
let state = tempfile::tempdir().unwrap();
let query =
"SELECT g, COUNT(*) AS n, SUM(x) AS s, MIN(x) AS mn, MAX(x) AS mx FROM events GROUP BY g";
let run = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", state.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("incremental-agg runs");
assert!(
run.used_incremental_agg("win_out"),
"supported shape uses the incremental-agg operator"
);
assert_eq!(
run.windows_closed("win_out"),
4,
"four tumbling windows close once each"
);
let out = run.output("win_out").expect("agg output");
let mut actual: Vec<(i64, i64, i64, i64, i64, i64, i64)> = Vec::new();
for b in out {
let c = |i: usize| b.column(i).as_any().downcast_ref::<Int64Array>().unwrap();
let (ws, we, g, n, s, mn, mx) = (c(0), c(1), c(2), c(3), c(4), c(5), c(6));
for r in 0..b.num_rows() {
actual.push((
ws.value(r),
we.value(r),
g.value(r),
n.value(r),
s.value(r),
mn.value(r),
mx.value(r),
));
}
}
actual.sort_unstable();
assert_eq!(
actual,
ground_truth_tumbling(&agg_corpus(), 100),
"incremental accumulators equal the ground-truth window aggregates"
);
}
#[test]
fn incremental_agg_recovers_across_a_crash() {
let query = "SELECT g, SUM(x) AS s FROM events GROUP BY g";
let oracle_state = tempfile::tempdir().unwrap();
let oracle = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", oracle_state.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("oracle runs");
assert!(oracle.used_incremental_agg("win_out"));
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let _run1 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_max_triggers(Some(2))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("capped run1");
let run2 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri)
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("resumed run2");
assert_eq!(
run2.resumed_from_epoch("win_out"),
Some(1),
"agg operator resumed from the checkpoint"
);
let pairs = |run: &knut_thund::backend::native::NativeRun| {
let mut v: Vec<(i64, i64, i64, i64)> = Vec::new();
for b in run.output("win_out").unwrap() {
let c = |i: usize| b.column(i).as_any().downcast_ref::<Int64Array>().unwrap();
let (ws, g, s) = (c(0), c(2), c(3));
for r in 0..b.num_rows() {
v.push((ws.value(r), 0, g.value(r), s.value(r)));
}
}
v.sort_unstable();
v
};
assert_eq!(
pairs(&run2),
pairs(&oracle),
"recovered agg result equals a single run"
);
}
fn ground_truth_avg(
rows: &[(i64, i64, i64, i64)],
size: i64,
) -> Vec<(i64, i64, i64, i64, f64, i64)> {
use std::collections::BTreeMap;
let mut acc: BTreeMap<(i64, i64), (i64, i64)> = BTreeMap::new(); for (_, et, g, x) in rows {
let s = et.div_euclid(size) * size;
let e = acc.entry((s, *g)).or_insert((0, 0));
e.0 += x;
e.1 += 1;
}
let mut out: Vec<_> = acc
.into_iter()
.map(|((s, g), (sum, n))| (s, s + size, g, n, sum as f64 / n as f64, sum))
.collect();
out.sort_by(|l, r| l.partial_cmp(r).unwrap());
out
}
#[test]
fn incremental_agg_avg_matches_ground_truth() {
use datafusion::arrow::array::Float64Array;
let state = tempfile::tempdir().unwrap();
let query = "SELECT g, COUNT(*) AS n, AVG(x) AS a, SUM(x) AS s FROM events GROUP BY g";
let run = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", state.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("incremental-agg with AVG runs");
assert!(
run.used_incremental_agg("win_out"),
"AVG is now a supported incremental shape"
);
assert_eq!(
run.windows_closed("win_out"),
4,
"four tumbling windows close once each"
);
let out = run.output("win_out").expect("agg output");
assert_eq!(
out[0].schema().field(4).data_type(),
&DataType::Float64,
"the AVG column is emitted as Float64"
);
let mut actual: Vec<(i64, i64, i64, i64, f64, i64)> = Vec::new();
for b in out {
let c = |i: usize| b.column(i).as_any().downcast_ref::<Int64Array>().unwrap();
let a = b.column(4).as_any().downcast_ref::<Float64Array>().unwrap();
let (ws, we, g, n, s) = (c(0), c(1), c(2), c(3), c(5));
for r in 0..b.num_rows() {
actual.push((
ws.value(r),
we.value(r),
g.value(r),
n.value(r),
a.value(r),
s.value(r),
));
}
}
actual.sort_by(|l, r| l.partial_cmp(r).unwrap());
assert_eq!(
actual,
ground_truth_avg(&agg_corpus(), 100),
"incremental AVG accumulators equal the ground-truth window averages"
);
}
#[test]
fn incremental_agg_avg_recovers_across_a_crash() {
use datafusion::arrow::array::Float64Array;
let query = "SELECT g, AVG(x) AS a FROM events GROUP BY g";
let oracle_state = tempfile::tempdir().unwrap();
let oracle = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", oracle_state.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("oracle runs");
assert!(oracle.used_incremental_agg("win_out"));
let state = tempfile::tempdir().unwrap();
let uri = format!("file://{}", state.path().display());
let _run1 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri.clone())
.with_max_triggers(Some(2))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("capped run1");
let run2 = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(uri)
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(query))
.expect("resumed run2");
assert_eq!(
run2.resumed_from_epoch("win_out"),
Some(1),
"AVG operator resumed from checkpoint"
);
let avgs = |run: &knut_thund::backend::native::NativeRun| {
let mut v: Vec<(i64, i64, f64)> = Vec::new();
for b in run.output("win_out").unwrap() {
let ws = b.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
let g = b.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
let a = b.column(3).as_any().downcast_ref::<Float64Array>().unwrap();
for r in 0..b.num_rows() {
v.push((ws.value(r), g.value(r), a.value(r)));
}
}
v.sort_by(|l, r| l.partial_cmp(r).unwrap());
v
};
assert_eq!(
avgs(&run2),
avgs(&oracle),
"recovered AVG state equals a single run"
);
}
#[test]
fn incremental_agg_falls_back_for_unsupported_shapes() {
let s1 = tempfile::tempdir().unwrap();
let computed = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", s1.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(
"SELECT g, SUM(x) + 1 AS s1 FROM events GROUP BY g",
))
.expect("computed projection runs via recompute-per-window");
assert!(
!computed.used_incremental_agg("win_out"),
"computing projection falls back"
);
assert_eq!(
computed.windows_closed("win_out"),
4,
"fallback path still closes every window"
);
let s2 = tempfile::tempdir().unwrap();
let filtered = NativeBackend::new()
.with_streaming_mode(StreamingMode::Incremental)
.with_state_root(format!("file://{}", s2.path().display()))
.with_stream_input("events", corpus_micro_batches())
.run(&agg_pipeline(
"SELECT COUNT(*) AS n FROM events WHERE x > 6",
))
.expect("filtered runs via recompute-per-window");
assert!(
!filtered.used_incremental_agg("win_out"),
"WHERE falls back"
);
}
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()),
projection: Vec::new(),
filter: None,
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:?}"
);
}