Custom sinks
A sink is two pieces, split across the process's two halves (Architecture):
RowEncoder— the CPU half. Turns one record into wire bytes. Runs on pipeline threads, where payload borrows are still valid.ShardWriter— the I/O half. Writes one sealed batch to one replica endpoint. Runs on sink workers in the I/O runtime.
Everything between is the framework's: chunking, per-shard batching to
max_rows/max_bytes/linger, replica rotation, circuit breakers,
retries, acknowledgements, backpressure. You write the two edges.
This page mirrors the sink half of
crates/etl/examples/custom_source_sink.rs (a JSON-lines-to-stdout sink)
and the bundle seam in crates/etl-core/src/sink/bundle.rs.
The CPU half: RowEncoder
#[derive(Clone)]
struct JsonLinesEncoder;
impl RowEncoder<Owned<Vec<u8>>> for JsonLinesEncoder {
fn encode<'buf>(
&mut self,
rec: &Record<Vec<u8>>,
buf: &mut bytes::BytesMut,
) -> Result<(), SinkError> {
use bytes::BufMut;
buf.put_slice(b"{\"partition\":");
buf.put_slice(rec.meta.partition.0.to_string().as_bytes());
buf.put_slice(b",\"value\":");
buf.put_slice(&rec.payload);
buf.put_slice(b"}\n");
Ok(())
}
}
Contracts:
- No I/O, ever.
encoderuns inside the hot loop on a pinned pipeline thread. A network call or blocking syscall here stalls polling for every lane on that thread. - Frames must concatenate. Chunks encoded on different pipeline threads are merged by shard workers into one batch, so your wire format must be valid as a plain concatenation of row encodings (RowBinary and NDJSON are; a format needing a global header belongs in the writer).
- Errors are record-level by default, subject to the sink stage's
ErrorPolicy(ChunkConfig::encode_policy, defaultSkip— counted in metrics). Return an error classedFatalto stop the pipeline regardless of policy: fatal means the encoder is broken (e.g. the row type can never match the target schema) and every subsequent record would fail identically. See Error handling.
The I/O half: ShardWriter
struct StdoutWriter;
impl ShardWriter for StdoutWriter {
type Endpoint = String; // a real sink holds a connected client here
fn write_batch(
&self,
endpoint: &String,
batch: &SealedBatch,
) -> impl Future<Output = Result<(), SinkError>> + Send {
async move {
/* write batch.frames to the endpoint */
Ok(())
}
}
}
Okis the durable-ack point. ReturnOk(())only after the target system has durably accepted the batch (for ClickHouse, the successful server ack; here, after printing). The framework releases the batch's acknowledgements onOk, which is what lets the source watermark advance past that data — returnOkearly and a crash loses records, violating at-least-once.- Retries reuse the sealed batch unchanged, rotating across healthy
replicas with capped exponential backoff.
SealedBatch::dedup_tokenis deterministic per batch: pass it to the target if it supports deduplication so same-boundary retries are idempotent (see Delivery guarantees for what dedup tokens do and do not cover). - Classify errors honestly. Retryable errors get retried; a
Fatalerror abandons the batch (its acks fail, the partition watermark stalls, andcheckpoint.stalled_fail_aftereventually fails the pipeline so the data replays after restart). - One writer instance is shared by every shard worker (
Send + Sync); the per-replica state lives inEndpoint.
Handing it to the builder: SinkParts
Hand-rolled sinks need no trait impl — SinkParts is the bundle:
let sink = SinkParts::new(
StdoutWriter,
// [shard][replica]: two shards, one replica each.
vec![vec!["shard-0".to_string()], vec!["shard-1".to_string()]],
SinkPoolConfig::default(),
)
.with_component_type("stdout");
let runtime = pipeline.sink(sink)?/* .chains(...) ... */;
SinkParts::new(writer, shard_endpoints, pool)— the endpoints are indexed[shard][replica]and must be non-empty with at least one replica per shard; the builder rejects ragged or empty topologies before anything spawns.SinkPoolConfigcarries the batching, in-flight, retry, and breaker tuning (Tuning)..with_component_type("...")— sets thecomponent_typemetric label (default"custom") on every sink series (Monitoring)..with_replica_labels(...)— display labels for per-replica metrics, same[shard][replica]shape; defaults to"{component_type}-{shard}-{replica}"..with_probe(...)— an optional repeatable connectivity probe. The runtime polls it at startup and periodically, driving the sinks-connected half of/readyz— without one, readiness can't reflect your sink. Probes should use their own client set: sharing the writer's connections would report the insert path healthy simply because probing keeps it warm.
The struct is #[non_exhaustive] — construct via new plus the with_*
methods, so fields can grow without breaking you. Queue depth between
pipeline threads and shard workers is the builder's knob, not the bundle's:
Pipeline::sink_with(bundle, SinkOptions::default().with_queue_capacity(n)).
The connector pattern: implementing SinkBundle
A reusable connector (the etl-clickhouse pattern) exposes a config-built
sink type and implements SinkBundle on it, so applications write one
line:
impl SinkBundle for MySink {
type Writer = MyShardWriter;
fn into_parts(self) -> SinkParts<MyShardWriter> {
SinkParts::new(self.writer, self.shard_endpoints, self.pool)
.with_component_type("mydb")
.with_probe(self.probe)
}
}
Pipeline::sink accepts anything implementing SinkBundle — SinkParts
itself implements it, which is why hand-rolled sinks skip the impl. The
only bound anywhere is ShardWriter, so your connector's client types
never appear in framework APIs (the dependency policy in
docs/DESIGN.md). Connector-specific flows the framework
cannot name — like ClickHouse's validate_schema producing the encoder's
row schema — stay concrete pre-steps in your factory, before into_parts.
Testing
etl-test drives chains end to end against capturing sinks, and the
assertions in custom_source_sink.rs (observe commits, then trigger
shutdown) are the template for verifying your durable-ack point actually
gates the watermark — see
Testing pipelines.