Skip to content

Navigation Menu

Sign in
Sign up

Latest commit

History

462 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

spate

At-least-once streaming ETL for Rust.

spate /speɪt/ — a river in sudden flood.

crates.io CI coverage docs.rs MSRV OpenSSF Scorecard

Documentation · Benchmarks · Quickstart · Examples · Changelog


Why Spate

Moving a stream into a warehouse usually means choosing between two shapes. Take a general-purpose stream processor and you inherit its delivery guarantees and its operational maturity, but your transformations are written in whatever language that runtime accepts, and the runtime is not yours to profile. Write the consumer loop yourself and you get the opposite trade: your language, your allocator, your profile, and every guarantee is now your problem, including the ones you find out about in production.

Spate is the third shape. Transformations are ordinary Rust functions, monomorphized into the pipeline rather than interpreted by it. Delivery, backpressure, checkpointing, rebalancing and drain-on-shutdown belong to the framework, and the properties they hold to are written down, numbered, and tested.

How it works

One process runs one pipeline, in four stages. The property each stage holds to is stated and numbered in docs/INVARIANTS.md.

Extract — one consumer per process. Partitions fan out across CPU-pinned threads as zero-copy lanes, so a record is read from the source buffer and never copied on the way in. A thread that cannot keep up pauses its lanes and keeps polling; it never blocks on a channel send, because a blocked poll loop is how a consumer gets evicted from its group.

Transform — operators are stateful closures chained in Rust. A chain compiles to a single loop over borrowed records with no per-record allocation. Record-level failure is Skip or Fail, never a silent drop: both are surfaced through metrics.

Load — sinks are sharded and replicated, running asynchronously on a shared I/O runtime. The chain routes rows into bounded per-shard queues; workers merge chunks, seal batches, rotate replicas and retry. The queue bound is the backpressure signal that reaches all the way back to Extract.

Observe — a source watermark advances only behind data the sink has acknowledged as durable, so commits trail delivery rather than leading it. Instrumentation is built on the metrics facade, so any recorder in that ecosystem works; a Prometheus scrape endpoint and health probes ship on the admin server.

Install

[dependencies]
spate = { version = "0.2", features = ["kafka", "clickhouse", "avro"] }

Nothing is enabled by default. A pipeline that only writes to ClickHouse never compiles the Kafka tree and never resolves rdkafka into its lockfile.

A taste

Operators are stateful closures composed into one monomorphized loop; YAML carries the tuning and connector configuration. This is a whole program, against in-memory mocks, so it needs no infrastructure to build or run:

use spate::prelude::*;
use spate_test::{TestDeserializer, TestEncoder, capture_sink, memory_source};
fn main() -> Result<(), Box<dyn std::error::Error>> {
 let config = PipelineConfig::from_str(
 "pipeline: { name: demo, threads: 1 }\n\
 checkpoint: { interval: 100ms }\n\
 source: { memory: {} }\n\
 sink: { capture: {} }",
 )?;
 let (source, _handle) = memory_source();
 let (sink, _script) = capture_sink(1, 1);
 let report = Pipeline::from_config(config)?
 .sink(sink)?
 .chains(|ctx| {
 // The sink's YAML `chunk:` block, bound before `with_metrics`
 // takes ownership of `ctx.pipeline`.
 let chunk_cfg = ctx.chunk();
 chain_owned::<Vec<u8>, _>(TestDeserializer::split_on(b','))
 .with_metrics(ctx.pipeline, "main")
 .filter(|word: &Vec<u8>| !word.is_empty())
 .map(|word: Vec<u8>| word.to_ascii_uppercase())
 .sink(TestEncoder, KeyHashRouter, chunk_cfg, ctx.queues, ctx.budget)
 .build()
 })
 .run(source)?;
 report.log();
 std::process::exit(report.exit_code());
}

Swap memory_source() for KafkaSource::from_component_config and the capture sink for a ClickHouse one, and the chain in the middle does not change. run installs signal handling and blocks until the pipeline has drained; tests use into_runtime instead, which hands back a shutdown handle so they can drive it. A version that scripts records through and asserts on what the sink captured runs from the repository:

cargo run -p spate --example memory_pipeline

Start at the examples index, which lists every example by what it shows, in five tiers, with what each one needs in order to run. kafka_avro_to_clickhouse is the fully-commented production assembly, custom_source_sink is the connector-author tutorial, and s3_coordinated_backfill runs two instances sharing one bounded backfill without either duplicating it. examples/docker covers containers and Kubernetes: probes, drain timeouts, sizing.

Delivery semantics

At-least-once. A batch's offsets commit only after every record derived from it is durably written (or intentionally dropped by filter/Skip policies). That holds across rebalances, shutdown, and failure, where the watermark stalls rather than ever committing past unacknowledged data. Duplicates remain possible: in-session retries are idempotent where sinks support it (ClickHouse deduplication tokens), but crash replay re-batches with new boundaries and will land rows twice. Design target tables to tolerate that (ReplacingMergeTree with a version column is the sanctioned ClickHouse pattern).

Connectors

Crate Feature Role
spate-kafka kafka Kafka source and sink on rdkafka: one consumer per process, partitions fanned across pipeline threads as zero-copy lanes.
spate-clickhouse clickhouse ClickHouse sink: Native or RowBinary encoded on pipeline threads, one deduplication-tokened INSERT per batch, replica rotation.
spate-s3 s3 Coordinated object-storage backfill source: a leader plans a prefix into splits, workers lease them with fenced progress.
spate-avro avro Avro deserialization: Confluent wire format, async schema-registry fetching that never blocks a pipeline thread.
spate-json json JSON deserialization: single, NDJSON and array framings, with an optional SIMD backend.
spate-coordination coordination Multi-instance work assignment: leader-computed sticky assignment over a pluggable store.
spate-datagen datagen Synthetic storefront-event source: referentially consistent orders, payments and refunds, with no broker or bucket to stand up first. A demo and test source; it keeps no durable progress.

And the framework itself:

Crate Role
spate The facade: the only crate applications depend on.
spate-core The engine: operator chains, source and sink abstractions, checkpointing, backpressure, config, metrics, the runtime.
spate-test In-memory sources and sinks with scripting handles; test your pipelines without infrastructure.

Each connector feature turns on one crate. Finer knobs are separate features, listed with what they pull in on docs.rs: a SIMD JSON backend, TLS and SASL for Kafka, chrono/time/uuid/rust_decimal column types for ClickHouse, a NATS JetStream store for coordination. Writing your own connector is a supported path, not a fork: see custom_source_sink.

Performance

Single-node throughput is measured. A change that reaches Rust runs allocation assertions and request-shape assertions, and one whose blast radius reaches a benched crate runs instruction-count benches too. Those compare counts rather than elapsed time, so a regression they report is a property of the change and not of how busy the runner was.

Wall-clock benches sit beside them as cargo bench targets, and nothing gates on one: a wall-clock figure is only worth reading against another taken on the same quiet hardware, and a shared CI runner is not that.

Testing

proptest covers the checkpoint tracker, the codecs and the assignment protocol across seven crates. loom models the tracker's concurrency directly; that module stays synchronous and free of async runtime types. Kafka runs against librdkafka's MockCluster on every pull request; brokers, ClickHouse and object stores run against real containers whenever a change reaches them, and on a schedule regardless. The work-assignment invariants each name the property test that enforces them.

The most useful contribution is one that proves a delivery guarantee wrong.

Documentation

The user guide is published at https://spate.kainth.dev/ (source in website/, content in docs/). The API reference is on docs.rs.

  • docs/INVARIANTS.md — the numbered properties the engine is arranged around.
  • docs/adr/ — one record per architectural decision, with the alternatives that were rejected and why.
  • docs/METRICS.md — every metric, its labels, and alerting starting points.
  • examples/docker — containers and Kubernetes.

Status

Under active initial development; APIs are not yet stable (0.x). Breaking changes ship in a minor bump and are called out in CHANGELOG.md. The newest 0.x minor is the supported one.

Contributing

CONTRIBUTING.md has what is worth contributing, how to build and test it, and how changes land. The Code of Conduct applies throughout. AI_POLICY.md covers what a contribution has to withstand, whatever wrote it.

Vulnerabilities go through GitHub's private advisory flow, never a public issue; see SECURITY.md.

License

Copyright 2026 Marcus Kainth.

Licensed under the Apache License, Version 2.0; see LICENSE.

Dependency licenses are inventoried in THIRD-PARTY.md; the full texts are published at spate.kainth.dev/licenses.

Contributions are accepted under the same terms, per Apache-2.0 §5. There is no CLA to sign.

About

High-performance, at-least-once ETL pipeline framework for Rust

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /