#![expect(deprecated, reason = "fixtures call the datum free functions directly")]
#[path = "e2e_support/mod.rs"]
mod support;
use apache_avro::types::Value;
use apache_avro::{Schema, to_avro_datum};
use rdkafka::ClientConfig;
use rdkafka::consumer::{BaseConsumer, Consumer};
use rdkafka::producer::{BaseProducer, BaseRecord, Producer};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use support::{CH_PASSWORD, Harness, http_get, metric_sum_where};
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::SyncRunner;
use testcontainers::{Container, GenericImage, ImageExt};
const OUTPUT_DEADLINE: Duration = Duration::from_secs(180);
const DRAIN_DEADLINE: Duration = Duration::from_secs(90);
const ORDER_SCHEMA_ID: u32 = 77;
const ORDER_SCHEMA: &str = r#"{"type":"record","name":"OrderPlaced","namespace":"spate.datagen","fields":[
{"name":"order_id","type":"long"},
{"name":"customer_id","type":"int"},
{"name":"region","type":"string"},
{"name":"placed_at","type":{"type":"long","logicalType":"timestamp-millis"}},
{"name":"lines","type":{"type":"array","items":
{"type":"record","name":"OrderLine","fields":[
{"name":"sku","type":"string"},
{"name":"qty","type":"int"},
{"name":"unit_cents","type":"int"}]}}}]}"#;
const PLACED_SCHEMA: &str = r#"{"type":"record","name":"OrderPlaced","namespace":"spate.datagen","fields":[
{"name":"order_id","type":"long"},
{"name":"customer_id","type":"int"},
{"name":"region","type":"string"},
{"name":"placed_at","type":{"type":"long","logicalType":"timestamp-millis"}},
{"name":"lines","type":{"type":"array","items":
{"type":"record","name":"OrderLine","fields":[
{"name":"sku","type":"string"},
{"name":"qty","type":"int"},
{"name":"unit_cents","type":"int"}]}}}]}"#;
const EVENT_UNION_SCHEMA: &str = spate_datagen::EVENT_SCHEMA_JSON;
fn example_bin(name: &str) -> PathBuf {
let mut dir = std::env::current_exe().expect("current_exe");
dir.pop();
if dir.file_name().is_some_and(|d| d == "deps") {
dir.pop();
}
let bin = dir.join("examples").join(name);
assert!(
bin.is_file(),
"example binary `{name}` is not at {}: the runner built test targets \
without building example targets, so this suite would test nothing",
bin.display()
);
bin
}
struct Rendered {
path: PathBuf,
admin: Option<SocketAddr>,
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind an ephemeral port")
.local_addr()
.expect("local_addr")
.port()
}
fn render_config(name: &str) -> Rendered {
let src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join(format!("{name}.yaml"));
let shipped =
std::fs::read_to_string(&src).unwrap_or_else(|e| panic!("read {}: {e}", src.display()));
let admin = shipped
.contains("0.0.0.0:9090")
.then(|| SocketAddr::from(([127, 0, 0, 1], free_port())));
let rendered = match admin {
Some(addr) => shipped.replace("0.0.0.0:9090", &addr.to_string()),
None => shipped,
};
assert!(
rendered.contains("admin:"),
"{name}.yaml: declares no `admin:` section, so it would take the default port"
);
assert!(
match admin {
Some(addr) => rendered.contains(&addr.to_string()),
None => rendered.contains("listen: none"),
} && !rendered.contains("0.0.0.0:9090"),
"{name}.yaml: could not rebind the admin server off the shared default port"
);
let dst = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}.yaml"));
std::fs::write(&dst, rendered).expect("write rendered config");
Rendered { path: dst, admin }
}
struct Example {
name: &'static str,
child: Child,
log: PathBuf,
}
fn spawn_as(
name: &'static str,
log_name: &str,
config: Option<&Path>,
env: &[(&str, String)],
) -> Example {
let log = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{log_name}.log"));
let out = std::fs::File::create(&log).expect("create example log");
let errs = out.try_clone().expect("clone example log handle");
let mut cmd = Command::new(example_bin(name));
if let Some(config) = config {
cmd.env("SPATE_CONFIG", config);
}
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd
.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(errs))
.spawn()
.unwrap_or_else(|e| panic!("spawn example `{name}`: {e}"));
Example { name, child, log }
}
fn spawn(name: &'static str, config: Option<&Path>, env: &[(&str, String)]) -> Example {
spawn_as(name, name, config, env)
}
impl Drop for Example {
fn drop(&mut self) {
if matches!(self.child.try_wait(), Ok(None)) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
impl Example {
fn log(&self) -> String {
std::fs::read_to_string(&self.log).unwrap_or_default()
}
fn wait_for(&mut self, what: &str, mut cond: impl FnMut() -> bool) {
let deadline = Instant::now() + OUTPUT_DEADLINE;
loop {
if cond() {
return;
}
if let Some(status) = self.child.try_wait().expect("try_wait") {
panic!(
"{}: exited {status} before {what}\n--- log ---\n{}",
self.name,
self.log()
);
}
assert!(
Instant::now() < deadline,
"{}: timed out waiting for {what}\n--- log ---\n{}",
self.name,
self.log()
);
std::thread::sleep(Duration::from_millis(250));
}
}
fn wait_exit(mut self, within: Duration) -> String {
self.reap("exit", within)
}
fn terminate(mut self) -> String {
let pid = self.child.id().to_string();
let killed = Command::new("kill")
.args(["-TERM", &pid])
.status()
.expect("run kill");
assert!(killed.success(), "{}: SIGTERM to {pid} failed", self.name);
self.reap("drain after SIGTERM", DRAIN_DEADLINE)
}
fn reap(&mut self, what: &str, within: Duration) -> String {
let deadline = Instant::now() + within;
loop {
if let Some(status) = self.child.try_wait().expect("try_wait") {
let log = self.log();
assert!(
status.success(),
"{}: {what} exited {status}\n--- log ---\n{log}",
self.name
);
return log;
}
if Instant::now() >= deadline {
let _ = self.child.kill();
let _ = self.child.wait();
panic!(
"{}: {what} did not finish within {within:?}\n--- log ---\n{}",
self.name,
self.log()
);
}
std::thread::sleep(Duration::from_millis(250));
}
}
}
fn ddl(h: &Harness, sql: &str) {
h.rt.block_on(h.ch_client().query(sql).execute())
.unwrap_or_else(|e| panic!("DDL failed: {e}\n{sql}"));
}
fn ch_env(h: &Harness) -> Vec<(&'static str, String)> {
vec![
("KAFKA_BROKERS", h.brokers.clone()),
("SCHEMA_REGISTRY_URL", h.registry_url.clone()),
("CLICKHOUSE_URL", h.ch_url.clone()),
("CLICKHOUSE_PASSWORD", CH_PASSWORD.to_string()),
]
}
fn produce_raw(brokers: &str, topic: &str, payloads: &[(Vec<u8>, Vec<u8>)]) {
let producer: BaseProducer = ClientConfig::new()
.set("bootstrap.servers", brokers)
.create()
.expect("producer");
for (key, payload) in payloads {
loop {
let record = BaseRecord::to(topic).payload(payload).key(key);
if producer.send(record).is_ok() {
break;
}
producer.poll(Duration::from_millis(50));
}
producer.poll(Duration::ZERO);
}
producer.flush(Duration::from_secs(30)).expect("flush");
}
fn confluent(id: u32, datum: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(5 + datum.len());
frame.push(0u8);
frame.extend_from_slice(&id.to_be_bytes());
frame.extend_from_slice(datum);
frame
}
fn encode(schema: &Schema, fields: Vec<(&str, Value)>) -> Vec<u8> {
let record = Value::Record(
fields
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
);
to_avro_datum(schema, record).expect("avro datum")
}
fn topic_count(brokers: &str, topic: &str) -> usize {
let probe: BaseConsumer = ClientConfig::new()
.set("bootstrap.servers", brokers)
.set("group.id", format!("probe-{}", std::process::id()))
.create()
.expect("probe consumer");
let timeout = Duration::from_secs(10);
let metadata = probe
.fetch_metadata(Some(topic), timeout)
.expect("topic metadata");
let partitions = metadata
.topics()
.iter()
.find(|t| t.name() == topic)
.map_or(0, |t| t.partitions().len());
(0..i32::try_from(partitions).expect("partition count"))
.map(|p| {
let (low, high) = probe
.fetch_watermarks(topic, p, timeout)
.expect("partition watermarks");
usize::try_from(high - low).expect("record count")
})
.sum()
}
#[test]
#[ignore = "requires Docker"]
fn kafka_to_clickhouse_examples_deliver_and_drain() {
let h = Harness::up();
let env = ch_env(&h);
h.register_schema(ORDER_SCHEMA_ID, ORDER_SCHEMA);
let order_schema = Schema::parse_str(ORDER_SCHEMA).expect("order schema");
ddl(
&h,
"CREATE TABLE orders (\
order_id UInt64, customer_id UInt32, region LowCardinality(String), \
placed_at DateTime64(3), total_cents UInt64) \
ENGINE = MergeTree ORDER BY order_id \
SETTINGS non_replicated_deduplication_window = 100",
);
h.create_topic("orders", 2);
let orders: i64 = 500;
let unlined: i64 = 10;
let order_line = |sku: &str, qty: i32, unit_cents: i32| {
Value::Record(vec![
("sku".into(), Value::String(sku.to_string())),
("qty".into(), Value::Int(qty)),
("unit_cents".into(), Value::Int(unit_cents)),
])
};
let payloads: Vec<(Vec<u8>, Vec<u8>)> = (0..orders + unlined)
.map(|i| {
let lines = if i < unlined {
vec![]
} else {
vec![
order_line("KBD-01", 2, 7_900),
order_line("MSE-01", 1, 3_500),
]
};
let datum = encode(
&order_schema,
vec![
("order_id", Value::Long(i)),
(
"customer_id",
Value::Int(i32::try_from(i % 1024).expect("customer")),
),
("region", Value::String("eu-west".into())),
("placed_at", Value::Long(1_700_000_000_000 + i)),
("lines", Value::Array(lines)),
],
);
(
i.to_string().into_bytes(),
confluent(ORDER_SCHEMA_ID, &datum),
)
})
.collect();
produce_raw(&h.brokers, "orders", &payloads);
let config = render_config("kafka_avro_to_clickhouse");
let mut example = spawn("kafka_avro_to_clickhouse", Some(&config.path), &env);
example.wait_for("every order row", || {
h.count("orders") >= u64::try_from(orders).expect("orders")
});
example.terminate();
assert_eq!(
h.scalar("SELECT uniqExact(order_id) FROM orders"),
u64::try_from(orders).expect("orders"),
"every lined order landed, and no line-less one did"
);
assert_eq!(
h.scalar("SELECT uniqExact(total_cents) FROM orders"),
1,
"every row carries the same total, summed from its lines"
);
assert_eq!(
h.scalar("SELECT any(total_cents) FROM orders"),
19_300,
"the total is the sum of qty x unit_cents over the order's lines"
);
let committed: i64 = h.committed("orders", "orders-etl", 2).into_iter().sum();
assert_eq!(
committed,
orders + unlined,
"the drain committed a watermark covering every order"
);
let placed_schema = Schema::parse_str(PLACED_SCHEMA).expect("placed schema");
ddl(
&h,
"CREATE TABLE order_lines (\
order_id UInt64, placed_at DateTime64(3), \
sku LowCardinality(String), qty UInt32, unit_cents UInt32) \
ENGINE = MergeTree ORDER BY (order_id, sku) \
SETTINGS non_replicated_deduplication_window = 100",
);
h.create_topic("order-placed", 2);
let placed: i64 = 100;
let per_order: i64 = 5;
let payloads: Vec<(Vec<u8>, Vec<u8>)> = (0..placed)
.map(|i| {
let lines: Vec<Value> = (0..per_order)
.map(|l| {
Value::Record(vec![
("sku".into(), Value::String(format!("KBD-{l:02}"))),
("qty".into(), Value::Int(i32::try_from(l).expect("qty") + 1)),
("unit_cents".into(), Value::Int(7_900)),
])
})
.collect();
let datum = encode(
&placed_schema,
vec![
("order_id", Value::Long(i)),
(
"customer_id",
Value::Int(i32::try_from(i % 1024).expect("customer")),
),
("region", Value::String("eu-west".into())),
("placed_at", Value::Long(1_700_000_000_000 + i)),
("lines", Value::Array(lines)),
],
);
(i.to_string().into_bytes(), datum)
})
.collect();
produce_raw(&h.brokers, "order-placed", &payloads);
let rows = u64::try_from(placed * per_order).expect("line rows");
let config = render_config("kafka_avro_flatmap_clickhouse");
let mut example = spawn("kafka_avro_flatmap_clickhouse", Some(&config.path), &env);
example.wait_for("every exploded order line", || {
h.count("order_lines") >= rows
});
example.terminate();
assert_eq!(
h.count("order_lines"),
rows,
"each order exploded into its lines and no more"
);
assert_eq!(
h.scalar(
"SELECT count() FROM order_lines \
WHERE toUnixTimestamp64Milli(placed_at) - 1700000000000 != order_id"
),
0,
"every line carries the order id of the order it was exploded from"
);
assert_eq!(
h.scalar("SELECT uniqExact(order_id) FROM order_lines"),
u64::try_from(placed).expect("placed"),
"the lines cover every order, one group each"
);
let committed: i64 = h
.committed("order-placed", "order-lines-etl", 2)
.into_iter()
.sum();
assert_eq!(
committed, placed,
"the drain committed a watermark covering every order"
);
let event_schema = Schema::parse_str(EVENT_UNION_SCHEMA).expect("event schema");
for (table, last) in [
("payments", ""),
("refunds", ", reason LowCardinality(String)"),
] {
ddl(
&h,
&format!(
"CREATE TABLE {table} (\
order_id UInt64, amount_cents UInt64{last}) \
ENGINE = MergeTree ORDER BY order_id \
SETTINGS non_replicated_deduplication_window = 100"
),
);
}
h.create_topic("storefront-events", 2);
let settled: i64 = 100;
let payloads: Vec<(Vec<u8>, Vec<u8>)> = (0..settled)
.flat_map(|i| {
let placed = Value::Union(
0,
Box::new(Value::Record(vec![
("order_id".into(), Value::Long(i)),
(
"customer_id".into(),
Value::Int(i32::try_from(i % 1024).expect("customer")),
),
("region".into(), Value::String("eu-west".into())),
("placed_at".into(), Value::Long(1_700_000_000_000 + i)),
("lines".into(), Value::Array(vec![])),
])),
);
let payment = Value::Union(
1,
Box::new(Value::Record(vec![
("order_id".into(), Value::Long(i)),
("amount_cents".into(), Value::Long(19_300)),
])),
);
let refund = Value::Union(
2,
Box::new(Value::Record(vec![
("order_id".into(), Value::Long(i)),
("amount_cents".into(), Value::Long(4_825)),
("reason".into(), Value::String("damaged".into())),
])),
);
[placed, payment, refund].map(|value| {
let datum = to_avro_datum(&event_schema, value).expect("avro datum");
(i.to_string().into_bytes(), datum)
})
})
.collect();
produce_raw(&h.brokers, "storefront-events", &payloads);
let per_table = u64::try_from(settled).expect("settled rows");
let config = render_config("multi_table_split");
let mut example = spawn("multi_table_split", Some(&config.path), &env);
example.wait_for("both split branches", || {
h.count("payments") >= per_table && h.count("refunds") >= per_table
});
let admin = config.admin.expect("the example names an admin address");
example.wait_for("the unrouted drops to be counted", || {
let (status, body) = http_get(admin, "/metrics");
status == 200
&& metric_sum_where(
&body,
"spate_operator_records_dropped_total",
r#"reason="unrouted""#,
) >= per_table as f64
});
let log = example.terminate();
assert!(
!log.contains("payload skipped by deserializer error policy"),
"a record was dropped by the deserializer, so the unmatched policy is \
not what this test exercised\n--- log ---\n{log}"
);
assert_eq!(
h.count("payments"),
per_table,
"the payments branch took the captured payments and nothing else"
);
assert_eq!(
h.count("refunds"),
per_table,
"the refunds branch took the issued refunds and nothing else"
);
assert_eq!(
h.scalar("SELECT count() FROM payments WHERE amount_cents = 19300"),
per_table,
"every payment landed with the amount it captured"
);
assert_eq!(
h.scalar("SELECT uniqExact(order_id) FROM payments"),
per_table,
"each payment kept its own order id rather than a constant"
);
assert_eq!(
h.scalar("SELECT count() FROM refunds WHERE reason = 'damaged' AND amount_cents = 4825"),
per_table,
"every refund landed with the reason and amount it was issued for"
);
let committed: i64 = h
.committed("storefront-events", "storefront-split-etl", 2)
.into_iter()
.sum();
assert_eq!(
committed,
settled * 3,
"the drain committed a watermark covering every event"
);
}
#[test]
#[ignore = "requires Docker"]
fn kafka_to_kafka_split_example_fans_out_and_drains() {
let h = Harness::up();
for topic in ["orders", "orders-eu", "orders-us"] {
h.create_topic(topic, 2);
}
let per_region: usize = 100;
let mut payloads = Vec::new();
let regions = ["eu-west", "eu-north", "us-east", "us-west", "apac"];
for i in 0..per_region {
for (r, region) in regions.iter().enumerate() {
let order_id = 1000 + i * regions.len() + r;
payloads.push((
format!("k{order_id}").into_bytes(),
format!("{region}:{order_id}:order_placed").into_bytes(),
));
}
}
produce_raw(&h.brokers, "orders", &payloads);
let config = render_config("kafka_to_kafka_split");
let env = vec![("KAFKA_BROKERS", h.brokers.clone())];
let mut example = spawn("kafka_to_kafka_split", Some(&config.path), &env);
let per_topic = per_region * 2;
example.wait_for("both region topics", || {
topic_count(&h.brokers, "orders-eu") >= per_topic
&& topic_count(&h.brokers, "orders-us") >= per_topic
});
let admin = config.admin.expect("the example names an admin address");
example.wait_for("the unrouted drops to be counted", || {
let (status, body) = http_get(admin, "/metrics");
status == 200
&& metric_sum_where(
&body,
"spate_operator_records_dropped_total",
r#"reason="unrouted""#,
) >= per_region as f64
});
example.terminate();
assert_eq!(
topic_count(&h.brokers, "orders-eu"),
per_topic,
"the eu topic holds both eu sub-regions and nothing else"
);
assert_eq!(
topic_count(&h.brokers, "orders-us"),
per_topic,
"the us topic holds both us sub-regions and nothing else"
);
let committed: i64 = h
.committed("orders", "orders-split-etl", 2)
.into_iter()
.sum();
assert_eq!(
committed,
i64::try_from(payloads.len()).expect("produced"),
"the drain committed a watermark covering every source record"
);
}
#[test]
#[ignore = "requires Docker"]
fn clickhouse_aggregating_mv_example_builds_states() {
let h = Harness::up();
ddl(
&h,
"CREATE TABLE orders_agg (\
region String, \
first_placed_at AggregateFunction(min, DateTime), \
last_placed_at AggregateFunction(max, DateTime), \
qty_by_sku AggregateFunction(sumMap, Map(String, UInt64))) \
ENGINE = AggregatingMergeTree ORDER BY region \
SETTINGS non_replicated_deduplication_window = 100",
);
ddl(
&h,
"CREATE TABLE orders_null (\
region String, placed_at DateTime, qty_by_sku Map(String, UInt64)) \
ENGINE = Null",
);
ddl(
&h,
"CREATE MATERIALIZED VIEW orders_mv TO orders_agg AS \
SELECT region, minState(placed_at) AS first_placed_at, \
maxState(placed_at) AS last_placed_at, \
sumMapState(qty_by_sku) AS qty_by_sku \
FROM orders_null GROUP BY region",
);
let config = render_config("clickhouse_aggregating_mv");
let env = vec![
("CLICKHOUSE_URL", h.ch_url.clone()),
("CLICKHOUSE_PASSWORD", CH_PASSWORD.to_string()),
];
spawn("clickhouse_aggregating_mv", Some(&config.path), &env).wait_exit(OUTPUT_DEADLINE);
assert_eq!(
h.scalar("SELECT uniqExact(region) FROM orders_agg"),
2,
"the view grouped the five orders into their two regions"
);
assert_eq!(
h.scalar(
"SELECT toUInt64(maxMerge(last_placed_at)) FROM orders_agg \
WHERE region = 'eu-west' GROUP BY region"
),
1_767_229_200,
"the max state over eu-west's three orders"
);
assert_eq!(
h.scalar(
"SELECT toUInt64(minMerge(first_placed_at)) FROM orders_agg \
WHERE region = 'eu-west' GROUP BY region"
),
1_767_225_600,
"the min state over eu-west's three orders"
);
assert_eq!(
h.scalar(
"SELECT toUInt64(length(m) = 3 AND m['KBD-01'] = 3 \
AND m['MSE-01'] = 3 AND m['MON-01'] = 3) \
FROM (SELECT sumMapMerge(qty_by_sku) AS m FROM orders_agg \
WHERE region = 'eu-west' GROUP BY region)"
),
1,
"the summed map over eu-west's three orders, keys included"
);
assert_eq!(
h.scalar(
"SELECT toUInt64(length(m) = 2 AND m['CBL-01'] = 15 AND m['DCK-01'] = 7) \
FROM (SELECT sumMapMerge(qty_by_sku) AS m FROM orders_agg \
WHERE region = 'us-east' GROUP BY region)"
),
1,
"the summed map over us-east's two orders"
);
}
#[test]
#[ignore = "requires Docker"]
fn nats_coordinated_backfill_example_covers_the_prefix() {
let nats: Container<GenericImage> = GenericImage::new("nats", "2.11-alpine")
.with_exposed_port(4222.tcp())
.with_wait_for(WaitFor::message_on_stderr("Server is ready"))
.with_cmd(["-js"])
.start()
.expect("start NATS (is Docker running? first run pulls nats:2.11-alpine)");
let port = nats.get_host_port_ipv4(4222).expect("nats client port");
let url = format!("nats://127.0.0.1:{port}");
let env = vec![
("NATS_URL", url.clone()),
("POD_NAME", "worker-e2e-a".to_string()),
];
let first = spawn_as(
"nats_coordinated_backfill",
"nats_coordinated_backfill.1",
None,
&env,
)
.wait_exit(Duration::from_secs(300));
assert!(
!first.contains("no coordinator injected"),
"the example ran against the durable store, not the solo fallback\n--- log ---\n{first}"
);
assert!(
first.contains("24000 records, covering 96 of 96 objects"),
"the sole instance covered the whole prefix\n--- log ---\n{first}"
);
let env = vec![("NATS_URL", url), ("POD_NAME", "worker-e2e-b".to_string())];
let second = spawn_as(
"nats_coordinated_backfill",
"nats_coordinated_backfill.2",
None,
&env,
)
.wait_exit(Duration::from_secs(120));
assert!(
second.contains("0 records, covering 0 of 96 objects"),
"a finished job stays finished: the second instance read nothing\n--- log ---\n{second}"
);
}