#![allow(clippy::print_stdout, clippy::print_stderr)]
use apache_avro::reader::datum::GenericDatumReader;
use apache_avro::writer::datum::GenericDatumWriter;
use apache_avro::{Schema, types::Value as AvroRecordValue};
use serde::Deserialize;
use spate::avro::{AvroDeserializerBuilder, AvroMode, AvroSettings, AvroValue, SchemaSource};
use spate::prelude::*;
use spate::source::LaneId;
use spate_test::{PipelineRun, TestEncoder, capture_sink, memory_source};
use std::error::Error;
use std::time::Duration;
const WRITER_V1: &str = r#"{"type":"record","name":"OrderPlaced","fields":[
{"name":"order_id","type":"string"},
{"name":"sku","type":"string"},
{"name":"quantity","type":"int"}]}"#;
const READER_V2: &str = r#"{"type":"record","name":"Order","aliases":["OrderPlaced"],"fields":[
{"name":"order_id","type":"string"},
{"name":"sku","type":"string"},
{"name":"quantity","type":"long"},
{"name":"currency","type":"string","default":"USD"}]}"#;
const READER_FIELD_ALIAS: &str = r#"{"type":"record","name":"Order","aliases":["OrderPlaced"],"fields":[
{"name":"order_id","type":"string"},
{"name":"item_code","type":"string","aliases":["sku"]},
{"name":"quantity","type":"long"},
{"name":"currency","type":"string","default":"USD"}]}"#;
const READER_UNRELATED_NAME: &str = r#"{"type":"record","name":"Unrelated","fields":[
{"name":"order_id","type":"string"},
{"name":"sku","type":"string"},
{"name":"quantity","type":"long"},
{"name":"currency","type":"string","default":"USD"}]}"#;
const WIDE_WRITER: &str =
r#"{"type":"record","name":"Count","fields":[{"name":"quantity","type":"long"}]}"#;
const NARROW_READER: &str =
r#"{"type":"record","name":"Count","fields":[{"name":"quantity","type":"int"}]}"#;
const CONFIG: &str = r#"
pipeline: { name: avro-evolution-demo, threads: 1 }
admin: { listen: none }
checkpoint: { interval: 200ms }
metrics: { exporter: none }
source: { memory: {} }
sink: { capture: {} }
"#;
#[derive(Debug, Deserialize)]
struct Order {
order_id: String,
#[serde(alias = "sku")]
item_code: String,
quantity: i64,
currency: String,
#[serde(default = "direct_channel")]
channel: String,
}
fn direct_channel() -> String {
"direct".to_string()
}
fn v1_datum(order_id: &str, sku: &str, quantity: i32) -> Result<Vec<u8>, Box<dyn Error>> {
let schema = Schema::parse_str(WRITER_V1)?;
let mut record =
apache_avro::types::Record::new(&schema).ok_or("WRITER_V1 is not a record schema")?;
record.put("order_id", order_id);
record.put("sku", sku);
record.put("quantity", quantity);
encode(&schema, record)
}
fn encode(schema: &Schema, value: impl Into<AvroRecordValue>) -> Result<Vec<u8>, Box<dyn Error>> {
Ok(GenericDatumWriter::builder(schema)
.build()?
.write_value_to_vec(value)?)
}
fn resolve(writer: &str, reader: &str, datum: &[u8]) -> Result<AvroValue, apache_avro::Error> {
let writer = Schema::parse_str(writer)?;
let reader = Schema::parse_str(reader)?;
GenericDatumReader::builder(&writer)
.reader_schema(&reader)
.build()?
.read_value(&mut &datum[..])
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
spate::telemetry::init(spate::telemetry::LogFormat::Pretty, "info");
let pipeline = Pipeline::from_config(PipelineConfig::from_str(CONFIG)?)?;
let settings = AvroSettings {
mode: AvroMode::Raw,
schema: Some(SchemaSource::inline(WRITER_V1)),
reader_schema: Some(SchemaSource::inline(READER_V2)),
..AvroSettings::default()
};
let deserializer = AvroDeserializerBuilder::from_settings(&settings, &pipeline.io_handle())?
.build_serde::<Order>()?;
let (source, handle) = memory_source();
let (sink, script) = capture_sink(1, 1);
let sink = sink.with_pool_config({
let mut cfg = SinkPoolConfig::default();
cfg.batch.linger = Duration::from_millis(50); cfg
});
let runtime = pipeline
.sink(sink)?
.chains(move |ctx| {
let chunk_cfg = ctx.chunk();
chain_owned::<Order, _>(deserializer.clone())
.with_metrics(ctx.pipeline, "main")
.map(|order: Order| {
format!(
"{}|{}|{}|{}|{}",
order.order_id,
order.item_code,
order.quantity,
order.currency,
order.channel
)
.into_bytes()
})
.sink(
TestEncoder,
KeyHashRouter,
chunk_cfg,
ctx.queues,
ctx.budget,
)
.build()
})
.runtime_options(RuntimeOptions {
handle_signals: false, ..RuntimeOptions::default()
})
.into_runtime(source)?;
let shutdown = runtime.shutdown_handle();
let run = PipelineRun::spawn(move || runtime.run());
let p0 = PartitionId(0);
handle.assign_lanes(&[(LaneId(0), p0)]);
let mut last = 0;
for (order_id, sku, quantity) in [
("ord-1001", "SKU-COFFEE", 2),
("ord-1002", "SKU-MUG", 1),
("ord-1003", "SKU-BEANS", 12),
] {
last = handle.push(
p0,
Some(order_id.as_bytes()),
&v1_datum(order_id, sku, quantity)?,
);
}
assert!(
handle.wait_committed(p0, last + 1, Duration::from_secs(30)),
"every payload commits (last committed: {:?})",
handle.last_committed(p0),
);
shutdown.trigger();
let report = run
.wait_exit(Duration::from_secs(30))
.expect("the pipeline drains after shutdown")?;
let rows: Vec<String> = script
.writes()
.iter()
.flat_map(|w| spate_test::decode_rows(&w.payload))
.map(|r| String::from_utf8_lossy(&r).into_owned())
.collect();
assert_eq!(rows.len(), 3, "every old-format payload must decode");
assert!(
rows.contains(&"ord-1001|SKU-COFFEE|2|USD|direct".to_string()),
"{rows:?}"
);
assert!(
rows.contains(&"ord-1002|SKU-MUG|1|USD|direct".to_string()),
"{rows:?}"
);
assert!(
rows.contains(&"ord-1003|SKU-BEANS|12|USD|direct".to_string()),
"{rows:?}"
);
let datum = v1_datum("ord-1004", "SKU-TEA", 3)?;
resolve(WRITER_V1, READER_UNRELATED_NAME, &datum)
.expect("record names are not compared during resolution");
let alias_err = resolve(WRITER_V1, READER_FIELD_ALIAS, &datum)
.expect_err("a reader field alias resolves after all");
assert!(alias_err.to_string().contains("item_code"), "{alias_err}");
let wide_schema = Schema::parse_str(WIDE_WRITER)?;
let mut wide =
apache_avro::types::Record::new(&wide_schema).ok_or("WIDE_WRITER is not a record")?;
wide.put("quantity", 5_000_000_000_i64);
let wide = encode(&wide_schema, wide)?;
let err = resolve(WIDE_WRITER, NARROW_READER, &wide)
.expect_err("a long too large for the reader's int must fail the record");
assert!(err.to_string().contains("5000000000"), "{err}");
println!("§ 3 narrowing rejected: {err}");
println!("\npipeline exit: {:?}", report.state);
println!("final watermarks: {:?}", report.final_watermarks);
println!("rows written ({}): {rows:?}", rows.len());
println!("reader field alias: {alias_err}");
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn runs_to_completion() {
super::main().expect("the example must run clean");
}
}