Expand description
ClickHouse sink for the Spate framework.
Writes directly to shard-local tables through the official
clickhouse crate: rows are encoded to RowBinary on the pipeline
threads (this crate’s own serializer), shipped as
pre-formatted frames, and inserted one INSERT ... FORMAT RowBinary
per sealed batch with a deterministic insert_deduplication_token.
Direct-to-shard writes beat Distributed-table inserts for an ETL
writer: bigger blocks, less merge pressure, and — crucially for
checkpointing — a synchronous server acknowledgement
(wait_end_of_query=1; write_batch returning Ok covers
materialized views too).
§⚠ Deduplication requires a window — silently off on plain MergeTree
Retries reuse the batch’s deduplication token, making them idempotent — but only if the server keeps a deduplication window:
Replicated*MergeTree: deduplication is on by default (replicated_deduplication_window = 10000).- Plain
MergeTree: the window defaults to0and token deduplication silently does nothing. Set it explicitly:
CREATE TABLE orders (...) ENGINE = MergeTree ORDER BY id
SETTINGS non_replicated_deduplication_window = 100;§At-least-once, honestly
Tokens cover same-batch retries (including on another replica after a
timeout). They do not cover crash replay: after a restart, data is
re-batched with different boundaries and different tokens, and replayed
rows will land again. Design target tables to tolerate duplicates —
ReplacingMergeTree with a version column is the sanctioned pattern.
Re-homing is a different hazard entirely: a key that moves to another
shard — changed shard weights, a changed sharding key, a changed shard
count — leaves its already-written rows behind on the old shard, and
nothing collapses them there. Dedup tokens are shard-scoped, and
ReplacingMergeTree merges within one shard’s local table only, so
the old copies persist indefinitely: unpruned reads through a
Distributed table return both homes’ rows, while pruned reads return
only the new home’s. Treat a re-homing change as a planned migration —
backfill or delete the moved keys’ old-shard rows — not as replay
noise that settles. A plain restart with unchanged topology is the
benign case: replayed rows land on the same shard, where the
patterns above apply.
§Shard-affinity routing (Distributed parity)
DistributedRouter (built via ClickHouseSink::router) places each
record on the same shard a ClickHouse Distributed table with sharding
expression xxHash64(<key column>) would — so SELECTs through the
Distributed table can prune shards (optimize_skip_unused_shards=1)
while inserts keep going direct-to-local. The ordering contract is the
operator’s: shards[i] in the sink config must be the cluster’s
shard_num = i + 1 (the remote_servers order), with matching
weights. The opt-in distributed_check config block verifies shard
count, weights, and the DDL’s sharding expression at startup
(ClickHouseSink::validate_distributed); the ordering itself it can
only warn about. See router for the algorithm.
§Column order is the wire contract
RowBinary carries no column names. The configured columns list and
the row struct’s field declaration order must match; reordering
either is a breaking change to the pipeline. See rowbinary for the
full type mapping.
§Wiring
sink:
clickhouse:
table: orders_local
columns: [id, name, amount]
shards:
- replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"]
- replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"]
# Only when inserting into JSON columns (as String fields of JSON
# text — see the rowbinary type table):
settings: { input_format_binary_read_json_as_string: "1" }config::from_component_config turns that section into a
ClickHouseWriter (the framework’s ShardWriter), per-shard
ClickHouseEndpoints, and the sink-pool configuration;
ClickHouseEncoder is the matching RowEncoder, parameterized by a
record family: owned rows use Owned<Row>, and the encode path itself
needs only Row: serde::Serialize.
§Wire format
The default is row-wise RowBinary (rowbinary). An opt-in columnar
Native encoder (native, format: native) transposes each chunk
into one self-describing block: it costs more client CPU but cuts server
parse CPU and compressed wire size substantially — build it from a fetched
schema via ClickHouseSink::native_schema and NativeEncoder.
Re-exports§
pub use config::ClickHouseSink;pub use config::ClickHouseSinkConfig;pub use config::Compression;pub use config::DistributedCheckSection;pub use config::Format;pub use config::SchemaValidation;pub use config::from_component_config;pub use native::NativeEncoder;pub use native::NativeError;pub use native::NativeSchema;pub use router::DistributedRouter;pub use router::KeyExtractor;pub use router::ShardKey;pub use rowbinary::RowBinaryError;pub use rowbinary::serialize_row;
Modules§
- config
- Opaque-section configuration and the sink factory.
- native
- ClickHouse Native (columnar, block-framed) encoder.
- router
- Distributed-parity shard routing: place each record on the same shard a
ClickHouse
Distributedtable with sharding expressionxxHash64(<key column>)would. - rowbinary
- A serde
Serializerproducing ClickHouse RowBinary. - serde
- Field-attribute serde modules for ClickHouse column types, mirroring
the
clickhousecrate’sclickhouse::serde::*modules.
Structs§
- Click
House Encoder - Encodes a record family’s
Serializerows into RowBinary via this crate’s serializer. Runs inside the chain’s terminal stage on pinned pipeline threads; sink workers ship the resulting frames as-is. - Click
House Endpoint - One connected ClickHouse replica. Wraps a
clickhouse::Client(its own hyper connection pool) — the crate type stays private so its 0.x breaking releases never become ours. - Click
House Writer - Writes sealed batches: one
INSERT ... FORMAT <fmt>per batch (the format — RowBinary or Native — is baked intoinsert_sql), carrying the batch’s deduplication token so retries — including on other replicas — are idempotent within the server’s dedup window. The transport is format-agnostic: frames concatenate to the request body (RowBinary rows or a stream of Native blocks).write_batchreturningOkis the durable-ack point (the server confirmed the insert, materialized views included, thanks towait_end_of_query=1). - Date32
Days - Days since the Unix epoch (signed), matching a
Date32column’s wire representation (Int32; the server accepts 1900-01-01 through 2299-12-31). - Date
Days - Days since the Unix epoch, matching a
Datecolumn’s wire representation (UInt16, so 1970-01-01 through 2149-06-06). - Date
Time64 Micros - Microseconds since the Unix epoch, matching a
DateTime64(6)column’s wire representation (Int64). - Date
Time64 Millis - Milliseconds since the Unix epoch, matching a
DateTime64(3)column’s wire representation (Int64). - Date
Time64 Nanos - Nanoseconds since the Unix epoch, matching a
DateTime64(9)column’s wire representation (Int64). - Date
Time64 Secs - Seconds since the Unix epoch, matching a
DateTime64(0)column’s wire representation (Int64). - Date
Time Seconds - Seconds since the Unix epoch, matching a
DateTimecolumn’s wire representation (UInt32). - Decimal32
- A pre-scaled
Decimal32(S)/Decimal(P ≤ 9, S)value: the inneri32isvalue × 10^SCALE, written little-endian. - Decimal64
- A pre-scaled
Decimal64(S)/Decimal(P ≤ 18, S)value: the inneri64isvalue × 10^SCALE, written little-endian. - Decimal128
- A pre-scaled
Decimal128(S)/Decimal(P ≤ 38, S)value: the inneri128isvalue × 10^SCALE, written little-endian. - Int256
- An
Int256column value: 32 bytes, little-endian, two’s complement. - PreEncoded
Rows - Passthrough for records that are already RowBinary-encoded rows
(
Vec<u8>payloads): appends the bytes verbatim. For pipelines that encode upstream or replicate pre-encoded data. One record must be exactly one encoded row — the framework counts rows by records. - RowSchema
- The validated, config-ordered expected schema. Produced by
crate::ClickHouseSink::validate_schema; consumed bycrate::ClickHouseEncoder::with_schemafor the first-record check. Opaque: holds noclickhousecrate types. - Time64
Micros - Microseconds, matching a
Time64(6)column’s wire representation (Int64). - Time64
Millis - Milliseconds, matching a
Time64(3)column’s wire representation (Int64). - Time64
Nanos - Nanoseconds, matching a
Time64(9)column’s wire representation (Int64). - Time64
Secs - Seconds, matching a
Time64(0)column’s wire representation (Int64). - Time
Seconds - Seconds, matching a
Timecolumn’s wire representation (Int32; the server accepts -999:59:59 through 999:59:59). - UInt256
- A
UInt256column value: 32 bytes, little-endian.
Enums§
- Distributed
Check Error - Startup DDL-parity guard failed. Fatal: fix the config or the DDL.
- Schema
Error - Schema validation failed at sink startup.
Type Aliases§
- Line
String - A
LineStringcolumn: an open sequence of points (Array(Point)). - Multi
Line String - A
MultiLineStringcolumn:Array(LineString). - Multi
Polygon - A
MultiPolygoncolumn:Array(Polygon). - Point
- A
Pointcolumn:(x, y)as twoFloat64s. - Polygon
- A
Polygoncolumn: an outer ring plus hole rings (Array(Ring)). - Ring
- A
Ringcolumn: a closed sequence of points (Array(Point)).