spate_clickhouse/lib.rs
1//! ClickHouse sink for the Spate framework.
2//!
3//! Writes **directly to shard-local tables** through the official
4//! `clickhouse` crate: rows are encoded to RowBinary on the pipeline
5//! threads (this crate's own [serializer](rowbinary)), shipped as
6//! pre-formatted frames, and inserted one `INSERT ... FORMAT RowBinary`
7//! per sealed batch with a deterministic `insert_deduplication_token`.
8//! Direct-to-shard writes beat `Distributed`-table inserts for an ETL
9//! writer: bigger blocks, less merge pressure, and — crucially for
10//! checkpointing — a synchronous server acknowledgement
11//! (`wait_end_of_query=1`; `write_batch` returning `Ok` covers
12//! materialized views too).
13//!
14//! # ⚠ Deduplication requires a window — silently off on plain MergeTree
15//!
16//! Retries reuse the batch's deduplication token, making them idempotent —
17//! **but only if the server keeps a deduplication window**:
18//!
19//! - `Replicated*MergeTree`: deduplication is on by default
20//! (`replicated_deduplication_window = 10000`).
21//! - **Plain `MergeTree`: the window defaults to `0` and token
22//! deduplication silently does nothing.** Set it explicitly:
23//!
24//! ```sql
25//! CREATE TABLE orders (...) ENGINE = MergeTree ORDER BY id
26//! SETTINGS non_replicated_deduplication_window = 100;
27//! ```
28//!
29//! # At-least-once, honestly
30//!
31//! Tokens cover *same-batch retries* (including on another replica after a
32//! timeout). They do **not** cover crash replay: after a restart, data is
33//! re-batched with different boundaries and different tokens, and replayed
34//! rows will land again. Design target tables to tolerate duplicates —
35//! `ReplacingMergeTree` with a version column is the sanctioned pattern.
36//!
37//! Re-homing is a different hazard entirely: a key that moves to another
38//! shard — changed shard weights, a changed sharding key, a changed shard
39//! count — leaves its already-written rows behind on the old shard, and
40//! nothing collapses them there. Dedup tokens are shard-scoped, and
41//! `ReplacingMergeTree` merges within one shard's local table only, so
42//! the old copies persist indefinitely: unpruned reads through a
43//! `Distributed` table return both homes' rows, while pruned reads return
44//! only the new home's. Treat a re-homing change as a planned migration —
45//! backfill or delete the moved keys' old-shard rows — not as replay
46//! noise that settles. A plain restart with unchanged topology is the
47//! benign case: replayed rows land on the *same* shard, where the
48//! patterns above apply.
49//!
50//! # Shard-affinity routing (Distributed parity)
51//!
52//! [`DistributedRouter`] (built via [`ClickHouseSink::router`]) places each
53//! record on the same shard a ClickHouse `Distributed` table with sharding
54//! expression `xxHash64(<key column>)` would — so SELECTs through the
55//! Distributed table can prune shards (`optimize_skip_unused_shards=1`)
56//! while inserts keep going direct-to-local. The ordering contract is the
57//! operator's: `shards[i]` in the sink config must be the cluster's
58//! `shard_num = i + 1` (the `remote_servers` order), with matching
59//! `weight`s. The opt-in `distributed_check` config block verifies shard
60//! count, weights, and the DDL's sharding expression at startup
61//! ([`ClickHouseSink::validate_distributed`]); the ordering itself it can
62//! only warn about. See [`router`] for the algorithm.
63//!
64//! # Column order is the wire contract
65//!
66//! RowBinary carries no column names. The configured `columns` list and
67//! the row struct's **field declaration order** must match; reordering
68//! either is a breaking change to the pipeline. See [`rowbinary`] for the
69//! full type mapping.
70//!
71//! # Wiring
72//!
73//! ```yaml
74//! sink:
75//! clickhouse:
76//! table: orders_local
77//! columns: [id, name, amount]
78//! shards:
79//! - replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"]
80//! - replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"]
81//! # Only when inserting into JSON columns (as String fields of JSON
82//! # text — see the rowbinary type table):
83//! settings: { input_format_binary_read_json_as_string: "1" }
84//! ```
85//!
86//! [`config::from_component_config`] turns that section into a
87//! [`ClickHouseWriter`] (the framework's `ShardWriter`), per-shard
88//! [`ClickHouseEndpoint`]s, and the sink-pool configuration;
89//! [`ClickHouseEncoder`] is the matching `RowEncoder`, parameterized by a
90//! record family: owned rows use `Owned<Row>`, and the encode path itself
91//! needs only `Row: serde::Serialize`.
92//!
93//! # Wire format
94//!
95//! The default is row-wise **RowBinary** ([`rowbinary`]). An opt-in columnar
96//! **Native** encoder ([`native`], `format: native`) transposes each chunk
97//! into one self-describing block: it costs more client CPU but cuts server
98//! parse CPU and compressed wire size substantially — build it from a fetched
99//! schema via [`ClickHouseSink::native_schema`] and [`NativeEncoder`].
100
101pub mod config;
102mod distributed;
103mod encoder;
104pub mod native;
105pub mod router;
106pub mod rowbinary;
107mod schema;
108pub mod serde;
109mod types;
110mod writer;
111
112pub use config::{
113 ClickHouseSink, ClickHouseSinkConfig, Compression, DistributedCheckSection, Format,
114 SchemaValidation, from_component_config,
115};
116pub use distributed::DistributedCheckError;
117pub use encoder::{ClickHouseEncoder, PreEncodedRows};
118pub use native::{NativeEncoder, NativeError, NativeSchema};
119pub use router::{DistributedRouter, KeyExtractor, ShardKey};
120pub use rowbinary::{RowBinaryError, serialize_row};
121pub use schema::{RowSchema, SchemaError};
122pub use types::*;
123pub use writer::{ClickHouseEndpoint, ClickHouseWriter};