Assembling a pipeline
etl::pipeline::Pipeline is the primary way to turn a config file, a
source, a sink, and your operator chain into a running process. This guide
walks the builder end to end; every step is a thin composition of public
primitives you can drop down to at any point (see
Manual assembly for the full desugaring).
The complete, compiling reference for everything below is
crates/etl/examples/kafka_avro_to_clickhouse.rs with its YAML next to it;
the zero-infrastructure variant is crates/etl/examples/memory_pipeline.rs.
The shape of an assembly
use etl::prelude::*;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let pipeline = Pipeline::from_path(Path::new("pipeline.yaml"))?;
// Connector construction: yours, one explicit line each.
let source = KafkaSource::from_component_config(&pipeline.config().source)?;
let sink = etl::clickhouse::config::from_component_config(&pipeline.config().sink)?;
let report = pipeline
.sink(sink)?
.chains(move |ctx| {
chain_owned::<Order, _>(deserializer.clone())
.with_metrics(ctx.pipeline, "main")
.sink(encoder.clone(), KeyHashRouter, ChunkConfig::default(),
ctx.queues, ctx.budget)
.build()
})
.run(source)?;
report.log();
std::process::exit(report.exit_code());
}
Step 1: from_path / from_config — the constructor owns process init
Pipeline::from_path loads the YAML (see
Configuring pipelines) and calls
Pipeline::from_config, which initializes the process in a fixed order:
- Telemetry —
telemetry::init(Json, "info"). Idempotent, first init wins: to customize the log format or filter, calletl::telemetry::inityourself before building the pipeline (the demos usePretty). - The metrics exporter — installed from the config's
metricssection. - The I/O runtime — a tokio runtime with
pipeline.io_threadsworkers (thread nameetl-io) that will host sink workers, the checkpointer, and the admin server.
Why does a builder do process init? Because metric handles bind to the
recorder present at the moment they are constructed. A handle built before
the exporter exists silently records into the void — no error, just missing
series. By making the constructor install the exporter, holding a Pipeline
guarantees a live recorder: every handle built afterwards, framework or
custom, is live. The ordering bug is not documented away; it is made
unconstructible. (Rationale: docs/DESIGN.md § Assembly and
§ Metrics.)
[!WARNING]
Pipeline::from_configmust be called outside any async runtime — from a plain thread, usuallymain. It owns a blocking tokio runtime, and dropping orblock_on-ing one inside async context panics, so the constructor refuses withBuildError::AsyncContextinstead.
Step 2: connector pre-steps — config(), io_handle(), block_on()
The framework never interprets the source, deserializer, and sink
sections of your YAML; pipeline.config() hands them to your connector
factories as opaque ComponentConfigs:
let source = KafkaSource::from_component_config(&pipeline.config().source)?;
Some connectors need async edge work before the pipeline runs. The builder's I/O runtime is already alive, so:
pipeline.io_handle()— atokio::runtime::Handlefor background tasks that must start beforerun(the Avro schema-registry fetcher takes one; see Avro).pipeline.block_on(future)— run an async pre-flight step to completion on the I/O runtime, blocking the current thread. The canonical use is ClickHouse schema validation (see Schema validation):
let encoder = match pipeline.block_on(sink.validate_schema())? {
Some(schema) => ClickHouseEncoder::<Owned<Order>>::with_schema(schema),
None => ClickHouseEncoder::<Owned<Order>>::new(),
};
Step 3: .sink(bundle) / .sink_with(bundle, options)
sink accepts anything implementing etl::sink::SinkBundle — every
connector's sink type does, and a hand-rolled SinkParts does too, so
custom sinks need no extra impl. From the bundle the builder derives the
per-shard chunk queues, registers per-shard metrics, spawns the sink workers
(SinkPool) on the I/O runtime, and wires the drain hook and the readiness
probe that drives /readyz.
sink_with adds SinkOptions, currently one knob: queue_capacity, the
per-shard chunk queue depth in chunks (default 8). It participates in the
backpressure sizing rule — see
Backpressure and
docs/DESIGN.md § Backpressure before raising it.
Calling .sink twice is an error (BuildError::SinkAlreadySet): the slot
is single-occupancy so multi-output routing can arrive later as an additive
API.
Step 4: .chains(|ctx| ...) — one chain per pipeline thread
The closure you pass to .chains is called once per pipeline thread and
must return that thread's operator chain as a Box<dyn RunnableChain>
(what .build() on a chain builder produces). Composition inside the
closure is fully monomorphized; the box is the same single per-batch erasure
boundary the engine always has.
Each call receives a ChainCtx by value with everything the terminal
.sink(...) stage needs:
| Field | What it is |
|---|---|
ctx.thread | Zero-based pipeline thread index. |
ctx.queues | This thread's clone of the shard-queue senders. |
ctx.budget | The shared in-flight byte budget. |
ctx.pipeline | The pipeline name — first argument to .with_metrics. |
Move the fields into the chain you build:
.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()
})
[!IMPORTANT] The drain contract: do not stash
ctx.queuesoutside the chain. The sink only begins draining once everyShardQueuesclone is dropped — that is how workers know no more data can arrive. The builder discharges this structurally: queues are lent per factory call,ChainCtxis deliberately notClone, and the chain's terminal stage drops its clone with the driver threads before the drain runs. A clone smuggled into long-lived state outside the returned chain outlives the drivers and turns a graceful drain into a deadline-bounded loud abandon (bounded bycheckpoint.drain_timeout— see Graceful shutdown).
Step 5: .runtime_options(...) (optional)
RuntimeOptions holds knobs that are deliberately not YAML: signal
handling, poll loop timings, the version string on etl_pipeline_info. The
defaults suit production. The one you will actually touch is
handle_signals: false in tests and demos, where you drive shutdown
yourself:
.runtime_options(RuntimeOptions {
handle_signals: false,
..RuntimeOptions::default()
})
Step 6: .run(source) or .into_runtime(source)
The source type enters only at this terminal call — the builder itself is non-generic, nameable, and storable.
.run(source)— assemble and run to completion, blocking until a shutdown signal drains the pipeline or a fatal error stops it. This is the production path..into_runtime(source)— assemble into aPipelineRuntimewithout running it, for callers that need ashutdown_handle()before a spawnedrun— tests and embedded pipelines:
let runtime = pipeline.into_runtime(source)?;
let shutdown = runtime.shutdown_handle();
let join = std::thread::spawn(move || runtime.run());
// ... drive data, assert, then:
shutdown.trigger();
let report = join.join().expect("pipeline thread")?;
Skipping a step is caught here: BuildError::MissingSink /
BuildError::MissingChains.
Step 7: the ExitReport
run returns an ExitReport carrying the terminal state (Completed or
Failed), the sink's drain report, and the final committed watermark per
partition. Three methods cover the common endings of main:
report.log()— log the outcome at the matching level (infoclean,errorfailed).report.exit_code()—0for a clean drain,1for a failure; pair withstd::process::exitso Kubernetes restarts failed pipelines.report.ok()— convert to aResultsomaincan?a failed run.
See Graceful shutdown for what "clean" means in detail.
Related
- Configuring pipelines — the YAML the builder loads.
- Testing pipelines — the
into_runtimepattern overetl-testmocks. - Manual assembly — every builder step desugared to public primitives.
- Architecture — what the assembled process looks like at runtime.