Your first pipeline: Kafka → Avro → ClickHouse
This tutorial builds the flagship shape: consume Confluent-framed Avro from
Kafka, validate and transform records in an operator chain, and write them
to ClickHouse in large batches — at-least-once, drained gracefully on
SIGTERM. It mirrors crates/etl/examples/kafka_avro_to_clickhouse.rs and
its YAML (crates/etl/examples/kafka_avro_to_clickhouse.yaml); every
snippet below is lifted from those files.
You need Kafka, a Confluent-compatible schema registry, and ClickHouse, plus
the full feature (see Installation). From the
repository:
cargo run --release -p etl --example kafka_avro_to_clickhouse --features full
1. Create the target table
Before running anything, create the table — and note the deduplication window setting:
CREATE TABLE orders (
id UInt64,
customer String,
amount_cents Int64,
ts_ms Int64
) ENGINE = MergeTree ORDER BY id
SETTINGS non_replicated_deduplication_window = 100;
[!WARNING] On plain
MergeTree, insert deduplication silently no-ops unless the table setsnon_replicated_deduplication_windowgreater than 0 (the server default is 0). Without it, the sink's dedup tokens do nothing and a retried batch lands twice.Replicated*MergeTreetables default to a window of 100 and don't need the setting. Even with the window, dedup tokens only cover same-batch retries — not crash replay. Read Delivery guarantees before choosing a table engine for production.
2. The record type
One struct travels end to end. Deserialize reads it from Avro (field
names match the writer schema); Serialize writes it as RowBinary:
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Order {
id: u64,
customer: String,
amount_cents: i64,
ts_ms: i64,
}
[!IMPORTANT] RowBinary carries no column names — the struct's field declaration order must match the
columnslist in the YAML. Order is the wire contract. Thevalidate_schemaoption below catches mismatches against the live table at startup instead of at 3 a.m.
3. The YAML
Tuning lives in YAML next to the binary; the operator graph never does.
Every ${VAR:-default} interpolates from the environment (in Kubernetes:
ConfigMap/Secret env). Framework sections (pipeline, checkpoint,
backpressure, metrics) are typed and validated; the source,
deserializer, and sink bodies belong to their connectors:
pipeline:
name: orders
# threads: 4 # default: available cores minus the I/O reserve
io_threads: 2
checkpoint:
interval: 5s
max_pending_batches: 1024
drain_timeout: 25s # keep below terminationGracePeriodSeconds
backpressure:
# Sizing rule (docs/DESIGN.md § Backpressure): the budget's low watermark
# must hold ~2x (shards x inflight x batch.max_bytes + queued chunks).
max_inflight_bytes: 1GiB
metrics:
exporter: prometheus
listen: 0.0.0.0:9090 # /metrics, /healthz, /readyz
source:
kafka:
brokers: ${KAFKA_BROKERS:-localhost:9092}
topic: ${KAFKA_TOPIC:-orders}
group_id: ${KAFKA_GROUP:-orders-etl}
commit_interval: 5s
rdkafka:
# Raw librdkafka passthrough. Properties the framework owns for
# correctness (offset storage, auto-commit) are rejected here.
auto.offset.reset: earliest
deserializer:
avro:
mode: confluent
registry:
url: ${SCHEMA_REGISTRY_URL:-http://localhost:8081}
prewarm_subjects: ["${KAFKA_TOPIC:-orders}-value"]
sink:
clickhouse:
table: ${CLICKHOUSE_TABLE:-orders}
# Column order is the RowBinary wire contract: it must match the row
# struct's field declaration order.
columns: [id, customer, amount_cents, ts_ms]
shards:
- replicas: ["${CLICKHOUSE_URL:-http://localhost:8123}"]
user: ${CLICKHOUSE_USER:-default}
password: ${CLICKHOUSE_PASSWORD:-}
# Fail startup (and the first record) when the columns, row struct,
# and live table disagree: off | names | full.
validate_schema: full
batch:
max_rows: 500000
max_bytes: 128MiB
linger: 1s
inflight:
max_per_shard: 2
The backpressure comment is load-bearing: with a saturated source, an undersized budget collapses throughput. The rule and the arithmetic are in Backpressure.
4. The binary
The builder owns the process plumbing (telemetry, metrics exporter, the shared I/O runtime, shard queues, sink workers, probes). The code you write is only what is genuinely this pipeline's: connector construction, schema validation, and the chain.
use etl::avro::AvroDeserializerBuilder;
use etl::clickhouse::ClickHouseEncoder;
use etl::kafka::KafkaSource;
use etl::prelude::*;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config_path = std::env::var("ETL_CONFIG")
.unwrap_or_else(|_| "pipeline.yaml".to_string());
let pipeline = Pipeline::from_path(Path::new(&config_path))?;
// Source: one Kafka consumer per process; partitions become lanes
// fanned across pipeline threads.
let source = KafkaSource::from_component_config(&pipeline.config().source)?;
// Deserializer: Confluent-framed Avro. Schemas arrive via an async
// fetcher on the I/O runtime; a cache miss never blocks a pipeline
// thread — the batch retries once the schema lands.
let deser_section = pipeline
.config()
.deserializer
.as_ref()
.ok_or("this pipeline requires a `deserializer` section")?;
let deserializer =
AvroDeserializerBuilder::from_component(deser_section, &pipeline.io_handle())?
.build_serde::<Order>()?;
// Sink: the connector turns its YAML section into everything the
// builder needs — writer, per-shard replica endpoints, pool tuning,
// readiness probe.
let sink = etl::clickhouse::config::from_component_config(&pipeline.config().sink)?;
// Opt-in fail-fast schema validation (validate_schema: names|full):
// checks the configured columns against every replica's live table
// NOW, before any thread spawns.
let encoder = match pipeline.block_on(sink.validate_schema())? {
Some(schema) => ClickHouseEncoder::<Owned<Order>>::with_schema(schema),
None => ClickHouseEncoder::<Owned<Order>>::new(),
};
// One identical chain per pipeline thread, fully monomorphized.
let report = pipeline
.sink(sink)?
.chains(move |ctx| {
chain_owned::<Order, _>(deserializer.clone())
.with_metrics(ctx.pipeline, "main")
.try_map(
|order: Order| {
if order.amount_cents >= 0 {
Ok(order)
} else {
Err("negative amount")
}
},
ErrorPolicy::Skip,
)
.sink(
encoder.clone(),
KeyHashRouter,
ChunkConfig::default(),
ctx.queues,
ctx.budget,
)
.build()
})
.run(source)?;
report.log();
std::process::exit(report.exit_code());
}
Walking the assembly:
Pipeline::from_pathloads the YAML and owns init: JSON logs (RUST_LOGoverrides the filter; calletl::telemetry::initfirst to customize), the metrics exporter — installed before any handle can exist — and the shared I/O runtime. PointETL_CONFIGelsewhere to reconfigure without recompiling.pipeline.io_handle()/pipeline.block_on(..)give connectors the I/O runtime beforerun— the schema-registry fetcher and thevalidate_schemapre-flight both use it..sink(sink)spawns the sharded sink workers and wires the drain and the/readyzprobe..chains(..)installs the per-thread chain factory. Thetry_mapwithErrorPolicy::Skipdrops bad records, counts them onetl_operator_records_dropped_total, and keeps going — see Error handling..run(source)blocks until SIGTERM/SIGINT (drain) or a fatal error, then returns an exit report.
Each step is a thin composition of public primitives you can drop down to —
the full mapping is in the etl::pipeline::Pipeline module docs and in
Manual assembly.
5. Run it, watch it, stop it
export KAFKA_BROKERS=localhost:9092
export SCHEMA_REGISTRY_URL=http://localhost:8081
export CLICKHOUSE_URL=http://localhost:8123
cargo run --release -p etl --example kafka_avro_to_clickhouse --features full
Probes and metrics are on the admin listener from the YAML:
curl localhost:9090/readyz # assignment received and sink connected
curl localhost:9090/healthz # poll loop alive, watermarks moving
curl localhost:9090/metrics # Prometheus scrape endpoint
The SIGTERM story. Send the process SIGTERM (what Kubernetes does on
pod termination) and it drains rather than dies: lanes stop polling, chains
flush, sink batches complete — bounded by checkpoint.drain_timeout — and
offsets commit synchronously before the process exits. If the sink is down
at the deadline, unflushed batches are abandoned loudly (metric + log)
and replay on restart: at-least-once holds either way. Keep drain_timeout
below your pod's terminationGracePeriodSeconds so the drain finishes
before the SIGKILL. Details in
Graceful shutdown.
Where next
- Delivery guarantees — what at-least-once buys you and what it doesn't.
- Kafka, Avro, and ClickHouse connector references.
- Docker and Monitoring to take this to production.