Skip to main content

spate_clickhouse/
config.rs

1//! Opaque-section configuration and the sink factory.
2//!
3//! The `sink: { clickhouse: { ... } }` YAML section deserializes into
4//! [`ClickHouseSinkConfig`]; [`from_component_config`] validates it and
5//! produces the writer, per-shard replica endpoints, and the framework's
6//! [`SinkPoolConfig`].
7
8use crate::distributed::{self, DistributedCheckError};
9use crate::router::{DistributedRouter, KeyExtractor};
10use crate::schema::{self, RowSchema, SchemaError};
11use crate::writer::{ClickHouseEndpoint, ClickHouseWriter};
12use serde::{Deserialize, Deserializer, de};
13use spate_core::config::{ComponentConfig, ConfigError};
14use spate_core::deser::RecFamily;
15use spate_core::sink::{
16    BatchConfig, BreakerConfig, InflightConfig, RetryConfig, SinkBundle, SinkParts, SinkPoolConfig,
17    SinkProbeFn, endpoint_probe,
18};
19use std::collections::BTreeMap;
20use std::str::FromStr;
21use std::sync::Arc;
22use std::time::Duration;
23
24/// The `clickhouse` sink section.
25///
26/// ```yaml
27/// sink:
28///   clickhouse:
29///     table: orders_local            # or db.orders_local
30///     columns: [id, name, amount]    # MUST match the row struct's field order
31///     shards:
32///       - replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"]
33///       - replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"]
34///     user: default
35///     password: ${CLICKHOUSE_PASSWORD}
36///     batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
37///     inflight: { max_per_shard: 2 }
38///     retry: { initial: 100ms, max: 10s, multiplier: 2.0, jitter: 0.2, max_attempts: 0 }
39///     breaker: { failure_threshold: 3, open_for: 5s, half_open_probes: 1 }
40///     timeouts: { send: 30s, end: 180s }
41///     compression: lz4               # off | lz4 | zstd | zstd:<1-22>
42///     settings: { insert_quorum: "auto" }   # extra per-insert settings
43/// ```
44#[derive(Clone, Debug, Deserialize, PartialEq)]
45#[serde(deny_unknown_fields)]
46pub struct ClickHouseSinkConfig {
47    /// Target table, optionally `database.table`-qualified.
48    pub table: String,
49    /// Column list for the `INSERT`. **Order is the wire contract**: it
50    /// must match the row struct's field declaration order.
51    pub columns: Vec<String>,
52    /// Shard topology: one entry per shard, each with its replica URLs.
53    /// Writes go directly to shard-local tables; replicas of a shard are
54    /// rotated per batch.
55    pub shards: Vec<ShardConfig>,
56    /// Default database for unqualified tables.
57    #[serde(default)]
58    pub database: Option<String>,
59    /// Username (interpolate secrets upstream via `${VAR}`).
60    #[serde(default)]
61    pub user: Option<String>,
62    /// Password (interpolate secrets upstream via `${VAR}`).
63    #[serde(default)]
64    pub password: Option<String>,
65    /// Extra per-insert ClickHouse settings (beyond the deduplication
66    /// settings this sink always sets).
67    #[serde(default)]
68    pub settings: BTreeMap<String, String>,
69    /// Batch sealing thresholds.
70    #[serde(default)]
71    pub batch: BatchConfig,
72    /// Concurrent in-flight batches per shard.
73    #[serde(default)]
74    pub inflight: InflightConfig,
75    /// Retry/backoff policy for failed writes.
76    #[serde(default)]
77    pub retry: RetryConfig,
78    /// Per-replica circuit breaker.
79    #[serde(default)]
80    pub breaker: BreakerConfig,
81    /// Client-side send/end timeouts.
82    #[serde(default)]
83    pub timeouts: TimeoutSection,
84    /// Transport (HTTP-body) compression for insert requests. `lz4` by
85    /// default (see [`Compression`]).
86    #[serde(default)]
87    pub compression: Compression,
88    /// Opt-in startup schema validation (see [`SchemaValidation`]).
89    /// `off` by default: today's behavior, no queries issued.
90    #[serde(default)]
91    pub validate_schema: SchemaValidation,
92    /// Insert wire format (see [`Format`]). `rowbinary` by default;
93    /// `native` selects the columnar block format (which always fetches the
94    /// column schema).
95    #[serde(default)]
96    pub format: Format,
97    /// Opt-in startup parity check against the cluster topology and a
98    /// `Distributed` table's DDL (see [`DistributedCheckSection`]).
99    /// Absent by default: no queries issued.
100    #[serde(default)]
101    pub distributed_check: Option<DistributedCheckSection>,
102}
103
104/// The `INSERT` wire format.
105///
106/// ```yaml
107/// sink:
108///   clickhouse:
109///     format: native   # rowbinary | native
110/// ```
111///
112/// `rowbinary` (default) streams rows; `native` transposes each chunk into a
113/// columnar block. Native is type-driven, so selecting it always fetches
114/// `system.columns` — [`build`] upgrades `validate_schema: off` to
115/// [`SchemaValidation::Names`] so the encoder can learn each column's type.
116/// Pair it with [`crate::NativeEncoder`] on the chain (via
117/// [`ClickHouseSink::native_schema`]); RowBinary pairs with
118/// [`crate::ClickHouseEncoder`].
119#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "lowercase")]
121pub enum Format {
122    /// Row-wise RowBinary (default).
123    #[default]
124    RowBinary,
125    /// Columnar Native (self-describing blocks).
126    Native,
127}
128
129impl Format {
130    /// The `FORMAT` keyword for the `INSERT` statement.
131    fn keyword(self) -> &'static str {
132        match self {
133            Format::RowBinary => "RowBinary",
134            Format::Native => "Native",
135        }
136    }
137}
138
139/// When to check the configured columns and row struct against the live
140/// table (via [`ClickHouseSink::validate_schema`]).
141///
142/// ```yaml
143/// sink:
144///   clickhouse:
145///     validate_schema: full   # off | names | full
146/// ```
147#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "lowercase")]
149pub enum SchemaValidation {
150    /// No validation (default).
151    #[default]
152    Off,
153    /// At startup: every configured column exists and is insertable on
154    /// every replica. At the first record: struct field names and order
155    /// match the configured columns.
156    Names,
157    /// [`SchemaValidation::Names`] plus a class-based type-compatibility
158    /// check per position (permissive: a `u32` may feed `UInt32`,
159    /// `DateTime`, or `IPv4`; unknown server types always pass; the
160    /// `Nullable`-vs-`Option` mismatch always fails — that one is wire
161    /// corruption).
162    Full,
163}
164
165/// Transport (HTTP-body) compression the client applies to insert requests.
166///
167/// This is wire-level compression negotiated per connection — it is unrelated
168/// to on-disk column `CODEC`s declared in table DDL, which stay the caller's
169/// responsibility. Deserialized from a scalar string:
170///
171/// ```yaml
172/// compression: lz4         # off | none | lz4 | zstd | zstd:<1-22>
173/// ```
174///
175/// `lz4` is fast and low-CPU (the default, matching prior behavior); `zstd`
176/// trades CPU for a better ratio and accepts an explicit level (`zstd` alone
177/// uses level 3). `off` disables compression.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub enum Compression {
180    /// No transport compression.
181    None,
182    /// LZ4 (default): fast, low CPU, moderate ratio.
183    #[default]
184    Lz4,
185    /// ZSTD at the given level (`1..=22`): higher ratio, more CPU.
186    Zstd(i32),
187}
188
189/// Default ZSTD level, matching the `clickhouse`/`zstd` crate default.
190const ZSTD_DEFAULT_LEVEL: i32 = 3;
191
192impl FromStr for Compression {
193    type Err = String;
194
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        match s {
197            "off" | "none" => Ok(Compression::None),
198            "lz4" => Ok(Compression::Lz4),
199            "zstd" => Ok(Compression::Zstd(ZSTD_DEFAULT_LEVEL)),
200            other => {
201                let raw = other.strip_prefix("zstd:").ok_or_else(|| {
202                    format!(
203                        "unknown compression `{other}`: expected off, lz4, zstd, or zstd:<1-22>"
204                    )
205                })?;
206                let level: i32 = raw.parse().map_err(|_| {
207                    format!("invalid zstd level `{raw}`: expected an integer in [1, 22]")
208                })?;
209                if !(1..=22).contains(&level) {
210                    return Err(format!("zstd level must be in [1, 22] (got {level})"));
211                }
212                Ok(Compression::Zstd(level))
213            }
214        }
215    }
216}
217
218impl<'de> Deserialize<'de> for Compression {
219    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
220    where
221        D: Deserializer<'de>,
222    {
223        // A scalar with an embedded level (`zstd:9`), so parse from the string
224        // rather than deriving a tagged enum; serde attaches the field path.
225        let s = String::deserialize(deserializer)?;
226        s.parse().map_err(de::Error::custom)
227    }
228}
229
230/// Map our stable [`Compression`] onto the `clickhouse` crate's enum. Private:
231/// the 0.x library type must never surface in this crate's public API.
232fn to_client_compression(c: Compression) -> clickhouse::Compression {
233    match c {
234        Compression::None => clickhouse::Compression::None,
235        Compression::Lz4 => clickhouse::Compression::Lz4,
236        Compression::Zstd(level) => clickhouse::Compression::Zstd(level),
237    }
238}
239
240/// One shard's replica endpoints.
241#[derive(Clone, Debug, Deserialize, PartialEq)]
242#[serde(deny_unknown_fields)]
243pub struct ShardConfig {
244    /// HTTP(S) URLs of this shard's replicas.
245    pub replicas: Vec<String>,
246    /// Distributed-parity weight: must equal this shard's `<weight>` in
247    /// the cluster's `remote_servers` entry (ClickHouse's default is 1).
248    /// Consumed by [`ClickHouseSink::router`]; irrelevant otherwise.
249    #[serde(default = "default_weight")]
250    pub weight: u32,
251}
252
253fn default_weight() -> u32 {
254    1
255}
256
257/// The opt-in `distributed_check:` block — a startup guard verifying that
258/// the sink config, the cluster topology, and the `Distributed` table's
259/// DDL agree (see [`ClickHouseSink::validate_distributed`]).
260///
261/// ```yaml
262/// sink:
263///   clickhouse:
264///     distributed_check:
265///       cluster: prod
266///       table: analytics.events_dist   # db-qualified, or bare like `table`
267///       sharding_key: sensor           # expected DDL expr = xxHash64(sensor)
268///       # sharding_expr: "xxHash64(sensor)"  # escape hatch — exactly one
269///       # endpoint: "http://ch-front:8123"   # default: shard 0, replica 0
270/// ```
271#[derive(Clone, Debug, Deserialize, PartialEq)]
272#[serde(deny_unknown_fields)]
273pub struct DistributedCheckSection {
274    /// The cluster the `Distributed` table is defined over.
275    pub cluster: String,
276    /// The `Distributed` table to check, optionally `db.table`-qualified
277    /// (an unqualified name resolves like the sink `table`).
278    pub table: String,
279    /// The sharding key column; the expected DDL expression becomes
280    /// `xxHash64(<sharding_key>)`. Exactly one of this or `sharding_expr`.
281    #[serde(default)]
282    pub sharding_key: Option<String>,
283    /// Escape hatch: the full expected sharding expression, compared
284    /// textually after normalization (whitespace and identifier quoting
285    /// stripped) — inherently more brittle than `sharding_key`.
286    #[serde(default)]
287    pub sharding_expr: Option<String>,
288    /// Endpoint to query for `system.clusters` / `system.tables`. The
289    /// `Distributed` table may live on a front node outside the `shards:`
290    /// list; defaults to the first replica of shard 0.
291    #[serde(default)]
292    pub endpoint: Option<String>,
293}
294
295/// Client-side timeouts for one insert.
296#[derive(Clone, Debug, Deserialize, PartialEq)]
297#[serde(deny_unknown_fields, default)]
298pub struct TimeoutSection {
299    /// Per-`send` timeout (one frame reaching the socket).
300    #[serde(with = "humantime_serde")]
301    pub send: Option<Duration>,
302    /// `end` timeout: the server fully processing the insert,
303    /// materialized views included.
304    #[serde(with = "humantime_serde")]
305    pub end: Option<Duration>,
306}
307
308impl Default for TimeoutSection {
309    fn default() -> Self {
310        TimeoutSection {
311            send: Some(Duration::from_secs(30)),
312            end: Some(Duration::from_secs(180)),
313        }
314    }
315}
316
317/// Everything the framework needs to run this sink.
318#[derive(Debug)]
319pub struct ClickHouseSink {
320    /// The `ShardWriter` implementation.
321    pub writer: ClickHouseWriter,
322    /// Per-shard replica endpoints, `shards[i][j]` = shard `i`, replica `j`.
323    pub endpoints: Vec<Vec<ClickHouseEndpoint>>,
324    /// Pool knobs mapped onto the framework's configuration.
325    pub pool: SinkPoolConfig,
326    /// The configured insert wire format.
327    format: Format,
328    /// What `validate_schema()` will check, captured from the config.
329    schema_check: schema::SchemaCheck,
330    /// An independent client set for readiness probing: sharing the insert
331    /// clients would report the write path healthy merely because probing
332    /// keeps its connections warm.
333    probe_endpoints: Arc<Vec<Vec<ClickHouseEndpoint>>>,
334    /// Per-shard weights in config order, for [`router`](Self::router).
335    shard_weights: Arc<[u32]>,
336    /// The captured `distributed_check` block, if configured.
337    distributed: Option<distributed::DistributedCheck>,
338}
339
340impl ClickHouseSink {
341    /// Opt-in startup schema validation. Call **after** [`build`] and
342    /// **before** `SinkPool::spawn` consumes `endpoints` — a failure here
343    /// exits before any pipeline thread or sink worker exists.
344    ///
345    /// Instant `Ok(None)` when `validate_schema: off`. Otherwise fetches
346    /// `system.columns` from every replica of every shard, fails fast
347    /// with a readable diff (missing / non-insertable columns, replica
348    /// drift, missing table), and returns the parsed schema to pass to
349    /// [`crate::ClickHouseEncoder::with_schema`] for the first-record
350    /// struct check.
351    pub async fn validate_schema(&self) -> Result<Option<Arc<RowSchema>>, SchemaError> {
352        schema::validate(&self.schema_check, &self.endpoints).await
353    }
354
355    /// The configured insert wire format.
356    #[must_use]
357    pub fn format(&self) -> Format {
358        self.format
359    }
360
361    /// Fetch the column schema and build a [`NativeSchema`] for a
362    /// [`crate::NativeEncoder`]. `format: native` always fetches
363    /// `system.columns` (see [`Format`]), so this returns a schema whenever
364    /// Native is configured. Call after [`build`], before the endpoints are
365    /// consumed — like [`validate_schema`](Self::validate_schema).
366    pub async fn native_schema(&self) -> Result<Arc<crate::native::NativeSchema>, SchemaError> {
367        let schema = self.validate_schema().await?.ok_or_else(|| {
368            SchemaError::Mismatch(
369                "sink.clickhouse: `format: native` requires a fetched schema (build upgrades \
370                 validate_schema to at least `names`)"
371                    .into(),
372            )
373        })?;
374        crate::native::NativeSchema::from_row_schema(&schema)
375            .map_err(|e| SchemaError::Mismatch(format!("sink.clickhouse: {e}")))
376    }
377
378    /// A readiness probe over every replica of every shard, using the
379    /// sink's independent probe client set (never the insert clients).
380    /// This is the probe [`SinkBundle::into_parts`] attaches; manual
381    /// assemblies hand it to `SinkRuntime.probe` directly.
382    #[must_use]
383    pub fn probe_fn(&self) -> SinkProbeFn {
384        endpoint_probe(self.writer.clone(), Arc::clone(&self.probe_endpoints))
385    }
386
387    /// A [`DistributedRouter`] over this sink's shard topology and
388    /// configured weights — the record-aware router whose placement
389    /// matches a `Distributed` table with sharding expression
390    /// `xxHash64(<key column>)`. Infallible: the weights were validated at
391    /// [`build`].
392    ///
393    /// `F` is not inferable from the extractor fn item (`Rec<'buf>`
394    /// projections are not injective) — name it:
395    /// `sink.router::<EventFam>(sensor_key)`.
396    #[must_use]
397    pub fn router<F: RecFamily>(&self, extract: KeyExtractor<F>) -> DistributedRouter<F> {
398        DistributedRouter::new(extract, &self.shard_weights)
399            .expect("config validation guarantees at least one shard and weights >= 1")
400    }
401
402    /// Opt-in startup DDL-parity guard. Instant `Ok(())` when no
403    /// `distributed_check` block is configured. Otherwise verifies shard
404    /// count, per-shard weights, and the `Distributed` table's sharding
405    /// expression against the live cluster, failing fast with a readable
406    /// diff — placement/DDL drift does not error at query time, it
407    /// silently returns wrong results under `optimize_skip_unused_shards`.
408    ///
409    /// Call **after** [`build`] and **before** the pipeline consumes the
410    /// sink — alongside [`validate_schema`](Self::validate_schema) /
411    /// [`native_schema`](Self::native_schema).
412    pub async fn validate_distributed(&self) -> Result<(), DistributedCheckError> {
413        match &self.distributed {
414            None => Ok(()),
415            Some(check) => check.verify().await,
416        }
417    }
418}
419
420impl SinkBundle for ClickHouseSink {
421    type Writer = ClickHouseWriter;
422
423    fn into_parts(self) -> SinkParts<ClickHouseWriter> {
424        let probe = self.probe_fn();
425        let replica_labels = self
426            .endpoints
427            .iter()
428            .map(|shard| shard.iter().map(|e| e.url().to_string()).collect())
429            .collect();
430        SinkParts::new(self.writer, self.endpoints, self.pool)
431            .with_component_type("clickhouse")
432            .with_replica_labels(replica_labels)
433            .with_probe(probe)
434    }
435}
436
437/// Build a [`ClickHouseSink`] from the opaque `sink: { clickhouse: ... }`
438/// component section.
439pub fn from_component_config(section: &ComponentConfig) -> Result<ClickHouseSink, ConfigError> {
440    let cfg: ClickHouseSinkConfig = section.deserialize_into()?;
441    build(cfg)
442}
443
444/// Build from an already-deserialized config (programmatic use).
445pub fn build(cfg: ClickHouseSinkConfig) -> Result<ClickHouseSink, ConfigError> {
446    validate(&cfg)?;
447
448    // Native is type-driven: it must fetch each column's type, so it always
449    // queries `system.columns`. Upgrade `off` to `names` (this still does no
450    // strict per-type check unless the user asked for `full`).
451    let schema_mode = match (cfg.format, cfg.validate_schema) {
452        (Format::Native, SchemaValidation::Off) => SchemaValidation::Names,
453        (_, mode) => mode,
454    };
455
456    let insert_sql = insert_statement(&cfg.table, &cfg.columns, cfg.format);
457    let writer = ClickHouseWriter::new(
458        insert_sql,
459        cfg.settings.clone().into_iter().collect(),
460        cfg.timeouts.send,
461        cfg.timeouts.end,
462    );
463
464    // Two independent client sets: inserts and readiness probes must not
465    // share connection pools (see `ClickHouseSink::probe_endpoints`).
466    let endpoints = make_endpoints(&cfg);
467    let probe_endpoints = Arc::new(make_endpoints(&cfg));
468
469    let shard_weights: Arc<[u32]> = cfg.shards.iter().map(|s| s.weight).collect();
470    let distributed = cfg.distributed_check.as_ref().map(|section| {
471        let url = section
472            .endpoint
473            .clone()
474            .unwrap_or_else(|| cfg.shards[0].replicas[0].clone());
475        let endpoint = ClickHouseEndpoint::new(client_for(&url, &cfg), url);
476        let expected_expr = match (&section.sharding_key, &section.sharding_expr) {
477            (Some(key), None) => format!("xxHash64({key})"),
478            (None, Some(expr)) => distributed::normalize(expr),
479            _ => unreachable!("validated: exactly one of sharding_key/sharding_expr"),
480        };
481        distributed::DistributedCheck {
482            endpoint,
483            cluster: section.cluster.clone(),
484            database: cfg.database.clone(),
485            table: section.table.clone(),
486            expected_expr,
487            weights: Arc::clone(&shard_weights),
488            replica_hosts: cfg
489                .shards
490                .iter()
491                .map(|s| {
492                    s.replicas
493                        .iter()
494                        .filter_map(|u| distributed::host_of(u))
495                        .collect()
496                })
497                .collect(),
498        }
499    });
500
501    let pool = SinkPoolConfig {
502        batch: cfg.batch,
503        inflight: cfg.inflight,
504        retry: cfg.retry,
505        breaker: cfg.breaker,
506    };
507
508    Ok(ClickHouseSink {
509        writer,
510        endpoints,
511        pool,
512        format: cfg.format,
513        schema_check: schema::SchemaCheck {
514            mode: schema_mode,
515            database: cfg.database.clone(),
516            table: cfg.table.clone(),
517            columns: cfg.columns.clone(),
518        },
519        probe_endpoints,
520        shard_weights,
521        distributed,
522    })
523}
524
525/// One configured client for `url`. Private: the `clickhouse` crate's 0.x
526/// `Client` type must never surface in this crate's public API.
527fn client_for(url: &str, cfg: &ClickHouseSinkConfig) -> clickhouse::Client {
528    let mut client = clickhouse::Client::default().with_url(url);
529    if let Some(db) = &cfg.database {
530        client = client.with_database(db);
531    }
532    if let Some(user) = &cfg.user {
533        client = client.with_user(user);
534    }
535    if let Some(password) = &cfg.password {
536        client = client.with_password(password);
537    }
538    client.with_compression(to_client_compression(cfg.compression))
539}
540
541/// One connected client per replica, `[shard][replica]`.
542fn make_endpoints(cfg: &ClickHouseSinkConfig) -> Vec<Vec<ClickHouseEndpoint>> {
543    cfg.shards
544        .iter()
545        .map(|shard| {
546            shard
547                .replicas
548                .iter()
549                .map(|url| ClickHouseEndpoint::new(client_for(url, cfg), url.clone()))
550                .collect()
551        })
552        .collect()
553}
554
555fn validate(cfg: &ClickHouseSinkConfig) -> Result<(), ConfigError> {
556    let fail = |msg: String| Err(ConfigError::Validation(format!("sink.clickhouse: {msg}")));
557
558    if cfg.shards.is_empty() {
559        return fail("at least one shard is required".into());
560    }
561    for (i, shard) in cfg.shards.iter().enumerate() {
562        if shard.replicas.is_empty() {
563            return fail(format!("shard {i} has no replicas"));
564        }
565        for url in &shard.replicas {
566            if !(url.starts_with("http://") || url.starts_with("https://")) {
567                return fail(format!("replica `{url}` is not an http(s) URL"));
568            }
569        }
570        // Weights are ClickHouse interval widths: a zero-weight shard
571        // receives nothing under Distributed parity and breaks the
572        // prefix-sum selection.
573        if shard.weight == 0 {
574            return fail(format!("shard {i} weight must be at least 1"));
575        }
576    }
577    if cfg.columns.is_empty() {
578        return fail("`columns` must list the insert columns in field order".into());
579    }
580    let mut seen = std::collections::HashSet::with_capacity(cfg.columns.len());
581    for col in &cfg.columns {
582        if !is_identifier(col) {
583            return fail(format!("column `{col}` is not a valid identifier"));
584        }
585        // Duplicate columns emit e.g. `INSERT INTO t (`id`, `id`)`, which
586        // ClickHouse rejects with DUPLICATE_COLUMN — a code the writer
587        // classifies retryable, so it would loop forever. Reject at load.
588        if !seen.insert(col.as_str()) {
589            return fail(format!("column `{col}` is listed more than once"));
590        }
591    }
592    let table_parts: Vec<&str> = cfg.table.split('.').collect();
593    if cfg.table.is_empty()
594        || table_parts.len() > 2
595        || !table_parts.iter().all(|p| is_identifier(p))
596    {
597        return fail(format!(
598            "table `{}` is not a valid (optionally database-qualified) identifier",
599            cfg.table
600        ));
601    }
602    if let Some(db) = &cfg.database
603        && !is_identifier(db)
604    {
605        return fail(format!("database `{db}` is not a valid identifier"));
606    }
607    if cfg.batch.max_rows == 0 || cfg.batch.max_bytes == 0 {
608        return fail("batch thresholds must be non-zero".into());
609    }
610    if cfg.inflight.max_per_shard == 0 {
611        return fail("inflight.max_per_shard must be at least 1".into());
612    }
613
614    // Retry policy: a sub-1.0 multiplier shrinks the delay instead of backing
615    // off, and a zero delay is not a backoff at all. The backoff saturates
616    // rather than trusting these bounds, so this catches the operator's
617    // intent at load, not a runtime hazard. The rules live in the framework
618    // so every sink enforces the same ones.
619    if let Err(why) = cfg.retry.validate() {
620        return fail(why.to_string());
621    }
622
623    // Compression: the string parser already bounds the level, but a
624    // programmatic `build()` caller can construct `Zstd(level)` directly, and
625    // an out-of-range level is rejected by the server mid-stream (a retryable
626    // code — an infinite loop). Reject at load, mirroring the retry checks.
627    if let Compression::Zstd(level) = cfg.compression
628        && !(1..=22).contains(&level)
629    {
630        return fail(format!(
631            "compression zstd level must be in [1, 22] (got {level})"
632        ));
633    }
634
635    // Circuit breaker: a zero failure threshold opens on the first outcome
636    // and zero half-open probes never lets a replica recover.
637    if cfg.breaker.failure_threshold == 0 {
638        return fail("breaker.failure_threshold must be at least 1".into());
639    }
640    if cfg.breaker.half_open_probes == 0 {
641        return fail("breaker.half_open_probes must be at least 1".into());
642    }
643
644    for reserved in [
645        "insert_deduplication_token",
646        "insert_deduplicate",
647        "wait_end_of_query",
648    ] {
649        if cfg.settings.contains_key(reserved) {
650            return fail(format!(
651                "setting `{reserved}` is managed by the sink and cannot be overridden"
652            ));
653        }
654    }
655
656    if let Some(check) = &cfg.distributed_check {
657        if !is_identifier(&check.cluster) {
658            return fail(format!(
659                "distributed_check: cluster `{}` is not a valid identifier",
660                check.cluster
661            ));
662        }
663        let parts: Vec<&str> = check.table.split('.').collect();
664        if check.table.is_empty() || parts.len() > 2 || !parts.iter().all(|p| is_identifier(p)) {
665            return fail(format!(
666                "distributed_check: table `{}` is not a valid (optionally \
667                 database-qualified) identifier",
668                check.table
669            ));
670        }
671        match (&check.sharding_key, &check.sharding_expr) {
672            (Some(_), Some(_)) | (None, None) => {
673                return fail(
674                    "distributed_check: set exactly one of sharding_key or sharding_expr".into(),
675                );
676            }
677            (Some(key), None) if !is_identifier(key) => {
678                return fail(format!(
679                    "distributed_check: sharding_key `{key}` is not a valid identifier"
680                ));
681            }
682            (None, Some(expr)) if expr.trim().is_empty() => {
683                return fail("distributed_check: sharding_expr must not be empty".into());
684            }
685            _ => {}
686        }
687        if let Some(url) = &check.endpoint
688            && !(url.starts_with("http://") || url.starts_with("https://"))
689        {
690            return fail(format!(
691                "distributed_check: endpoint `{url}` is not an http(s) URL"
692            ));
693        }
694    }
695    Ok(())
696}
697
698/// Strict identifier: `[A-Za-z_][A-Za-z0-9_]*`. Validated before being
699/// backtick-quoted into SQL, so no escaping is ever needed.
700fn is_identifier(s: &str) -> bool {
701    let mut chars = s.chars();
702    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
703        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
704}
705
706fn insert_statement(table: &str, columns: &[String], format: Format) -> String {
707    let table = table
708        .split('.')
709        .map(|p| format!("`{p}`"))
710        .collect::<Vec<_>>()
711        .join(".");
712    let cols = columns
713        .iter()
714        .map(|c| format!("`{c}`"))
715        .collect::<Vec<_>>()
716        .join(", ");
717    format!("INSERT INTO {table} ({cols}) FORMAT {}", format.keyword())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use spate_core::config::ComponentConfig;
724
725    fn component(yaml: &str) -> ComponentConfig {
726        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
727        ComponentConfig::new("clickhouse", value)
728    }
729
730    const MINIMAL: &str = r#"
731table: orders
732columns: [id, name]
733shards:
734  - replicas: ["http://a:8123"]
735"#;
736
737    #[test]
738    fn minimal_config_builds_with_framework_defaults() {
739        let sink = from_component_config(&component(MINIMAL)).unwrap();
740        assert_eq!(
741            sink.writer.insert_sql(),
742            "INSERT INTO `orders` (`id`, `name`) FORMAT RowBinary"
743        );
744        assert_eq!(sink.endpoints.len(), 1);
745        assert_eq!(sink.endpoints[0].len(), 1);
746        assert_eq!(sink.pool, SinkPoolConfig::default());
747    }
748
749    #[test]
750    fn qualified_table_and_knobs_map_through() {
751        let sink = from_component_config(&component(
752            r#"
753table: analytics.orders
754columns: [id]
755shards:
756  - replicas: ["http://a:8123", "http://b:8123"]
757  - replicas: ["http://c:8123"]
758batch: { max_rows: 1000, max_bytes: 1MiB, linger: 250ms }
759inflight: { max_per_shard: 4 }
760retry: { initial: 50ms, max: 2s, multiplier: 3.0, jitter: 0.5, max_attempts: 7 }
761breaker: { failure_threshold: 9, open_for: 30s, half_open_probes: 2 }
762timeouts: { send: 5s, end: 60s }
763settings: { insert_quorum: "auto" }
764"#,
765        ))
766        .unwrap();
767        assert_eq!(
768            sink.writer.insert_sql(),
769            "INSERT INTO `analytics`.`orders` (`id`) FORMAT RowBinary"
770        );
771        assert_eq!(sink.endpoints.len(), 2);
772        assert_eq!(sink.endpoints[0].len(), 2);
773        assert_eq!(sink.pool.batch.max_rows, 1000);
774        assert_eq!(sink.pool.batch.max_bytes, 1024 * 1024);
775        assert_eq!(sink.pool.batch.linger, Duration::from_millis(250));
776        assert_eq!(sink.pool.inflight.max_per_shard, 4);
777        assert_eq!(sink.pool.retry.max_attempts, 7);
778        assert_eq!(sink.pool.breaker.failure_threshold, 9);
779    }
780
781    #[test]
782    fn validation_rejects_bad_configs() {
783        let cases = [
784            ("table: orders\ncolumns: [id]\nshards: []", "shard"),
785            (
786                "table: orders\ncolumns: [id]\nshards: [{replicas: []}]",
787                "replicas",
788            ),
789            (
790                "table: orders\ncolumns: [id]\nshards: [{replicas: [\"tcp://x\"]}]",
791                "http",
792            ),
793            (
794                "table: orders\ncolumns: []\nshards: [{replicas: [\"http://a\"]}]",
795                "columns",
796            ),
797            (
798                "table: orders\ncolumns: [\"id; DROP\"]\nshards: [{replicas: [\"http://a\"]}]",
799                "identifier",
800            ),
801            (
802                "table: \"or`ders\"\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]",
803                "identifier",
804            ),
805            (
806                "table: a.b.c\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]",
807                "identifier",
808            ),
809            (
810                "table: orders\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\nsettings: {insert_deduplication_token: \"x\"}",
811                "managed by the sink",
812            ),
813        ];
814        for (yaml, needle) in cases {
815            let err = from_component_config(&component(yaml)).unwrap_err();
816            let msg = err.to_string();
817            assert!(
818                msg.contains(needle),
819                "expected `{needle}` in error for {yaml}: {msg}"
820            );
821        }
822    }
823
824    #[test]
825    fn validation_rejects_bad_retry_and_breaker() {
826        // Every one of these reaches the write loop (or a broken breaker) at
827        // runtime rather than failing at load if the rules are dropped. The
828        // rules live in `RetryConfig::validate`; this asserts the ClickHouse
829        // sink still applies them and still reports them under its prefix.
830        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
831        let cases = [
832            ("retry: { multiplier: 0.5 }", "multiplier"),
833            ("retry: { multiplier: -2.0 }", "multiplier"),
834            ("retry: { multiplier: .nan }", "multiplier"),
835            ("retry: { multiplier: .inf }", "multiplier"),
836            ("retry: { jitter: 1.5 }", "jitter"),
837            ("retry: { jitter: -0.1 }", "jitter"),
838            ("retry: { jitter: .nan }", "jitter"),
839            ("retry: { initial: 0s }", "non-zero"),
840            ("retry: { max: 0s }", "non-zero"),
841            ("retry: { initial: 10s, max: 1s }", "must not exceed"),
842            ("breaker: { failure_threshold: 0 }", "failure_threshold"),
843            ("breaker: { half_open_probes: 0 }", "half_open_probes"),
844        ];
845        for (extra, needle) in cases {
846            let yaml = format!("{base}{extra}");
847            let err = from_component_config(&component(&yaml)).unwrap_err();
848            assert!(
849                err.to_string().contains(needle),
850                "expected `{needle}` for `{extra}`: {err}"
851            );
852        }
853    }
854
855    #[test]
856    fn valid_retry_and_breaker_still_build() {
857        let sink = from_component_config(&component(
858            "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n\
859             retry: { initial: 100ms, max: 10s, multiplier: 1.0, jitter: 0.0 }\n\
860             breaker: { failure_threshold: 1, open_for: 5s, half_open_probes: 1 }",
861        ));
862        assert!(sink.is_ok(), "boundary-valid config must build: {sink:?}");
863    }
864
865    #[test]
866    fn validate_schema_modes_parse() {
867        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
868        for (yaml, expected) in [
869            ("", SchemaValidation::Off),
870            ("validate_schema: off\n", SchemaValidation::Off),
871            ("validate_schema: names\n", SchemaValidation::Names),
872            ("validate_schema: full\n", SchemaValidation::Full),
873        ] {
874            let cfg: ClickHouseSinkConfig = serde_yaml::from_str(&format!("{base}{yaml}")).unwrap();
875            assert_eq!(cfg.validate_schema, expected, "for `{yaml}`");
876        }
877        let err = serde_yaml::from_str::<ClickHouseSinkConfig>(&format!(
878            "{base}validate_schema: everything\n"
879        ))
880        .unwrap_err();
881        assert!(err.to_string().contains("everything"), "{err}");
882    }
883
884    #[test]
885    fn validation_rejects_duplicate_columns() {
886        let err = from_component_config(&component(
887            "table: t\ncolumns: [id, name, id]\nshards: [{replicas: [\"http://a\"]}]",
888        ))
889        .unwrap_err();
890        assert!(err.to_string().contains("more than once"), "{err}");
891    }
892
893    #[test]
894    fn unknown_fields_are_rejected_with_a_path() {
895        let err = from_component_config(&component(
896            "table: orders\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\nbatch: {max_rowz: 5}",
897        ))
898        .unwrap_err();
899        assert!(err.to_string().contains("max_rowz"), "{err}");
900    }
901
902    #[test]
903    fn compression_parses_and_defaults_to_lz4() {
904        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
905        for (yaml, expected) in [
906            ("", Compression::Lz4),
907            ("compression: off\n", Compression::None),
908            ("compression: none\n", Compression::None),
909            ("compression: lz4\n", Compression::Lz4),
910            ("compression: zstd\n", Compression::Zstd(ZSTD_DEFAULT_LEVEL)),
911            ("compression: \"zstd:9\"\n", Compression::Zstd(9)),
912            ("compression: \"zstd:1\"\n", Compression::Zstd(1)),
913            ("compression: \"zstd:22\"\n", Compression::Zstd(22)),
914        ] {
915            let cfg: ClickHouseSinkConfig = serde_yaml::from_str(&format!("{base}{yaml}")).unwrap();
916            assert_eq!(cfg.compression, expected, "for `{yaml}`");
917        }
918    }
919
920    #[test]
921    fn compression_rejects_invalid_strings_with_a_path() {
922        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
923        for (value, needle) in [
924            ("gzip", "unknown compression"),
925            ("\"zstd:0\"", "[1, 22]"),
926            ("\"zstd:99\"", "[1, 22]"),
927            ("\"zstd:x\"", "invalid zstd level"),
928        ] {
929            let err = serde_yaml::from_str::<ClickHouseSinkConfig>(&format!(
930                "{base}compression: {value}\n"
931            ))
932            .unwrap_err();
933            assert!(
934                err.to_string().contains(needle),
935                "expected `{needle}` for `{value}`: {err}"
936            );
937        }
938    }
939
940    #[test]
941    fn format_parses_and_defaults_to_rowbinary() {
942        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
943        for (yaml, expected) in [
944            ("", Format::RowBinary),
945            ("format: rowbinary\n", Format::RowBinary),
946            ("format: native\n", Format::Native),
947        ] {
948            let cfg: ClickHouseSinkConfig = serde_yaml::from_str(&format!("{base}{yaml}")).unwrap();
949            assert_eq!(cfg.format, expected, "for `{yaml}`");
950        }
951    }
952
953    #[test]
954    fn native_format_emits_native_sql_and_forces_a_schema_fetch() {
955        let sink = from_component_config(&component(
956            "table: t\ncolumns: [id, name]\nshards: [{replicas: [\"http://a\"]}]\nformat: native",
957        ))
958        .unwrap();
959        assert_eq!(
960            sink.writer.insert_sql(),
961            "INSERT INTO `t` (`id`, `name`) FORMAT Native"
962        );
963        assert_eq!(sink.format(), Format::Native);
964        // Native upgrades `validate_schema: off` to `names` so the encoder
965        // can learn each column's type from `system.columns`.
966        assert_eq!(sink.schema_check.mode, SchemaValidation::Names);
967    }
968
969    #[test]
970    fn native_format_keeps_an_explicit_full_validation_mode() {
971        let sink = from_component_config(&component(
972            "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n\
973             format: native\nvalidate_schema: full",
974        ))
975        .unwrap();
976        assert_eq!(sink.schema_check.mode, SchemaValidation::Full);
977    }
978
979    #[test]
980    fn validation_rejects_out_of_range_programmatic_zstd_level() {
981        let mut cfg: ClickHouseSinkConfig =
982            serde_yaml::from_str("table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n")
983                .unwrap();
984        cfg.compression = Compression::Zstd(99);
985        let err = build(cfg).unwrap_err();
986        assert!(err.to_string().contains("[1, 22]"), "{err}");
987    }
988
989    #[test]
990    fn shard_weights_parse_and_default_to_one() {
991        let cfg: ClickHouseSinkConfig = serde_yaml::from_str(
992            "table: t\ncolumns: [id]\nshards:\n\
993             \x20 - replicas: [\"http://a\"]\n\
994             \x20 - replicas: [\"http://b\"]\n\
995             \x20   weight: 9\n",
996        )
997        .unwrap();
998        assert_eq!(cfg.shards[0].weight, 1, "weight defaults to 1");
999        assert_eq!(cfg.shards[1].weight, 9);
1000    }
1001
1002    #[test]
1003    fn zero_shard_weight_is_rejected() {
1004        let err = from_component_config(&component(
1005            "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"], weight: 0}]",
1006        ))
1007        .unwrap_err();
1008        assert!(err.to_string().contains("weight"), "{err}");
1009    }
1010
1011    #[test]
1012    fn sink_router_captures_config_weights_in_order() {
1013        use crate::router::ShardKey;
1014        use spate_core::deser::Owned;
1015
1016        let sink = from_component_config(&component(
1017            "table: t\ncolumns: [id]\nshards:\n\
1018             \x20 - replicas: [\"http://a\"]\n\
1019             \x20   weight: 9\n\
1020             \x20 - replicas: [\"http://b\"]\n\
1021             \x20   weight: 10\n",
1022        ))
1023        .unwrap();
1024        // `&Vec<u8>` (not `&[u8]`) is forced by the KeyExtractor fn-pointer
1025        // type: its argument is `&'a Rec<'buf>` = `&'a Vec<u8>`.
1026        #[allow(clippy::ptr_arg)]
1027        fn key(rec: &Vec<u8>) -> ShardKey<'_> {
1028            ShardKey::Bytes(rec)
1029        }
1030        let router = sink.router::<Owned<Vec<u8>>>(key);
1031        assert_eq!(router.shard_count(), 2);
1032        // The docs' 9/10 example: remainder 8 → shard 0, remainder 9 → shard 1.
1033        assert_eq!(router.shard_for_hash(8), 0);
1034        assert_eq!(router.shard_for_hash(9), 1);
1035    }
1036
1037    #[test]
1038    fn distributed_check_parses_with_endpoint_defaulting_to_first_replica() {
1039        let sink = from_component_config(&component(
1040            "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a:8123\"]}]\n\
1041             distributed_check: { cluster: prod, table: db.t_dist, sharding_key: id }",
1042        ))
1043        .unwrap();
1044        let check = sink.distributed.as_ref().expect("check captured");
1045        assert_eq!(check.cluster, "prod");
1046        assert_eq!(check.table, "db.t_dist");
1047        assert_eq!(check.expected_expr, "xxHash64(id)");
1048        assert_eq!(check.endpoint.url(), "http://a:8123");
1049    }
1050
1051    #[test]
1052    fn distributed_check_requires_exactly_one_of_key_or_expr() {
1053        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
1054        for check in [
1055            "distributed_check: { cluster: c, table: t_dist }",
1056            "distributed_check: { cluster: c, table: t_dist, sharding_key: id, sharding_expr: \"xxHash64(id)\" }",
1057        ] {
1058            let err = from_component_config(&component(&format!("{base}{check}"))).unwrap_err();
1059            assert!(
1060                err.to_string().contains("exactly one"),
1061                "for `{check}`: {err}"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn distributed_check_rejects_bad_cluster_table_key_and_endpoint() {
1068        let base = "table: t\ncolumns: [id]\nshards: [{replicas: [\"http://a\"]}]\n";
1069        let cases = [
1070            (
1071                "distributed_check: { cluster: \"pr od\", table: t_dist, sharding_key: id }",
1072                "cluster",
1073            ),
1074            (
1075                "distributed_check: { cluster: c, table: a.b.c, sharding_key: id }",
1076                "table",
1077            ),
1078            (
1079                "distributed_check: { cluster: c, table: t_dist, sharding_key: \"id; DROP\" }",
1080                "sharding_key",
1081            ),
1082            (
1083                "distributed_check: { cluster: c, table: t_dist, sharding_expr: \"  \" }",
1084                "sharding_expr",
1085            ),
1086            (
1087                "distributed_check: { cluster: c, table: t_dist, sharding_key: id, endpoint: \"tcp://x\" }",
1088                "endpoint",
1089            ),
1090        ];
1091        for (check, needle) in cases {
1092            let err = from_component_config(&component(&format!("{base}{check}"))).unwrap_err();
1093            assert!(
1094                err.to_string().contains(needle),
1095                "expected `{needle}` for `{check}`: {err}"
1096            );
1097        }
1098    }
1099}