Skip to main content

spate_clickhouse/
distributed.rs

1//! Opt-in startup DDL-parity guard for `Distributed`-table deployments.
2//!
3//! When the sink config carries a `distributed_check` block, startup
4//! verifies that the cluster topology and the `Distributed` table's DDL
5//! agree with the sink config — shard count, per-shard weights, and the
6//! sharding expression. Placement/DDL drift does not error at query time:
7//! under `optimize_skip_unused_shards=1` it silently returns wrong
8//! results, which is why this fails startup instead.
9//!
10//! What is verified: shard count, weights, the sharding expression, and
11//! the DDL's cluster argument. What is documented-only: that config shard
12//! `i` actually IS the cluster's `shard_num = i + 1` — HTTP replica URLs
13//! are not reliably mappable onto the cluster's native `host:port`
14//! entries (proxies, DNS aliases, the 8123/9000 port split), so ordering
15//! stays the operator's contract. A best-effort hostname cross-check
16//! warns (never fails) on apparent cross-wiring.
17
18use crate::writer::ClickHouseEndpoint;
19use serde::Deserialize;
20use std::fmt::Write as _;
21use std::sync::Arc;
22
23/// Startup DDL-parity guard failed. Fatal: fix the config or the DDL.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum DistributedCheckError {
27    /// A system-table query failed. The user opted into fail-fast
28    /// verification, so connectivity problems fail startup too —
29    /// distinguishably from mismatches.
30    #[error("sink.clickhouse.distributed_check: could not fetch {what} from {url}: {reason}")]
31    Fetch {
32        /// What was being fetched (`cluster topology` / `table engine`).
33        what: &'static str,
34        /// The endpoint URL queried.
35        url: String,
36        /// Human-readable cause.
37        reason: String,
38    },
39    /// Config and cluster/DDL disagree (pre-formatted diff).
40    #[error("{0}")]
41    Mismatch(String),
42}
43
44/// One replica row of `system.clusters`, crate-private.
45#[derive(Clone, Debug, clickhouse::Row, Deserialize)]
46pub(crate) struct ClusterReplicaRow {
47    pub(crate) shard_num: u32,
48    pub(crate) shard_weight: u32,
49    pub(crate) host_name: String,
50}
51
52/// The engine of the checked table, crate-private.
53#[derive(Clone, Debug, clickhouse::Row, Deserialize)]
54pub(crate) struct TableEngineRow {
55    pub(crate) engine: String,
56    pub(crate) engine_full: String,
57}
58
59/// What `build()` captures for a later `validate_distributed()` call.
60#[derive(Debug)]
61pub(crate) struct DistributedCheck {
62    /// The endpoint to query (`distributed_check.endpoint`, defaulting to
63    /// the first replica of shard 0).
64    pub(crate) endpoint: ClickHouseEndpoint,
65    /// The cluster named by the `Distributed` DDL.
66    pub(crate) cluster: String,
67    /// Default database for an unqualified `table`.
68    pub(crate) database: Option<String>,
69    /// The `Distributed` table, optionally `db.table`-qualified.
70    pub(crate) table: String,
71    /// The expected sharding expression, already normalized.
72    pub(crate) expected_expr: String,
73    /// Configured per-shard weights, config order.
74    pub(crate) weights: Arc<[u32]>,
75    /// Configured replica URL hostnames per shard — advisory cross-check.
76    pub(crate) replica_hosts: Vec<Vec<String>>,
77}
78
79impl DistributedCheck {
80    fn target(&self) -> (Option<&str>, &str) {
81        match self.table.split_once('.') {
82            Some((db, tbl)) => (Some(db), tbl),
83            None => (self.database.as_deref(), &self.table),
84        }
85    }
86
87    fn display_table(&self) -> String {
88        match self.target() {
89            (Some(db), tbl) => format!("`{db}`.`{tbl}`"),
90            (None, tbl) => format!("`{tbl}`"),
91        }
92    }
93
94    fn mismatch(&self, detail: String) -> DistributedCheckError {
95        DistributedCheckError::Mismatch(format!("sink.clickhouse.distributed_check: {detail}"))
96    }
97
98    /// Run the guard. Query order is fixed — cluster topology, then table
99    /// engine — and the mock tests depend on it.
100    pub(crate) async fn verify(&self) -> Result<(), DistributedCheckError> {
101        let replicas = self
102            .endpoint
103            .client()
104            .query(
105                "SELECT shard_num, shard_weight, host_name FROM system.clusters \
106                 WHERE cluster = ? ORDER BY shard_num, replica_num",
107            )
108            .bind(&self.cluster)
109            .fetch_all::<ClusterReplicaRow>()
110            .await
111            .map_err(|e| DistributedCheckError::Fetch {
112                what: "cluster topology",
113                url: self.endpoint.url().to_string(),
114                reason: e.to_string(),
115            })?;
116        if replicas.is_empty() {
117            return Err(self.mismatch(format!(
118                "cluster `{}` not found in system.clusters on {}",
119                self.cluster,
120                self.endpoint.url()
121            )));
122        }
123
124        // Collapse replica rows to per-shard weights; shard_num is 1-based
125        // and consecutive in cluster-config order.
126        let mut shard_weights: Vec<u32> = Vec::new();
127        for row in &replicas {
128            let idx = row.shard_num as usize;
129            if idx == 0 || idx > replicas.len() {
130                return Err(self.mismatch(format!(
131                    "cluster `{}` reports an unexpected shard_num {} — cannot map \
132                     shards positionally",
133                    self.cluster, row.shard_num
134                )));
135            }
136            if idx == shard_weights.len() + 1 {
137                shard_weights.push(row.shard_weight);
138            } else if idx <= shard_weights.len() {
139                // Another replica of an already-seen shard.
140                if shard_weights[idx - 1] != row.shard_weight {
141                    return Err(self.mismatch(format!(
142                        "cluster `{}` shard_num {} reports inconsistent weights",
143                        self.cluster, row.shard_num
144                    )));
145                }
146            } else {
147                return Err(self.mismatch(format!(
148                    "cluster `{}` shard numbering is not consecutive at shard_num {}",
149                    self.cluster, row.shard_num
150                )));
151            }
152        }
153
154        if shard_weights.len() != self.weights.len() {
155            return Err(self.mismatch(format!(
156                "the sink config has {} shard(s) but cluster `{}` has {}",
157                self.weights.len(),
158                self.cluster,
159                shard_weights.len()
160            )));
161        }
162        for (i, (&configured, &live)) in self.weights.iter().zip(shard_weights.iter()).enumerate() {
163            if configured != live {
164                return Err(self.mismatch(format!(
165                    "config shard {i} has weight {configured} but cluster shard_num {} \
166                     has weight {live}",
167                    i + 1
168                )));
169            }
170        }
171
172        // Advisory only: exact-hostname hits under the wrong shard_num
173        // suggest cross-wiring; absence of a match proves nothing (HTTP
174        // URLs vs native host:port), so this can never be a hard check.
175        for (i, hosts) in self.replica_hosts.iter().enumerate() {
176            for host in hosts {
177                let expected_num = (i + 1) as u32;
178                let under_expected = replicas
179                    .iter()
180                    .any(|r| r.shard_num == expected_num && r.host_name == *host);
181                let elsewhere: Vec<u32> = replicas
182                    .iter()
183                    .filter(|r| r.host_name == *host && r.shard_num != expected_num)
184                    .map(|r| r.shard_num)
185                    .collect();
186                if !under_expected && !elsewhere.is_empty() {
187                    tracing::warn!(
188                        host,
189                        config_shard = i,
190                        cluster_shard_nums = ?elsewhere,
191                        "sink.clickhouse.distributed_check: config shard {i} replica host \
192                         appears under a different cluster shard_num — the shards: list \
193                         may not be in remote_servers order",
194                    );
195                }
196            }
197        }
198
199        // The Distributed table's DDL.
200        let (db, table) = self.target();
201        let query = match db {
202            Some(db) => self
203                .endpoint
204                .client()
205                .query(
206                    "SELECT engine, engine_full FROM system.tables \
207                     WHERE database = ? AND name = ?",
208                )
209                .bind(db)
210                .bind(table),
211            None => self
212                .endpoint
213                .client()
214                .query(
215                    "SELECT engine, engine_full FROM system.tables \
216                     WHERE database = currentDatabase() AND name = ?",
217                )
218                .bind(table),
219        };
220        let engines = query.fetch_all::<TableEngineRow>().await.map_err(|e| {
221            DistributedCheckError::Fetch {
222                what: "table engine",
223                url: self.endpoint.url().to_string(),
224                reason: e.to_string(),
225            }
226        })?;
227        let Some(row) = engines.first() else {
228            return Err(self.mismatch(format!(
229                "table {} not found (or not visible to this user) on {}",
230                self.display_table(),
231                self.endpoint.url()
232            )));
233        };
234        if row.engine != "Distributed" {
235            return Err(self.mismatch(format!(
236                "table {} exists but its engine is {}, not Distributed",
237                self.display_table(),
238                row.engine
239            )));
240        }
241
242        let Some(args) = engine_args(&row.engine_full) else {
243            return Err(self.mismatch(format!(
244                "could not parse the Distributed engine arguments of {}; raw \
245                 engine_full: {}",
246                self.display_table(),
247                row.engine_full
248            )));
249        };
250        if args.len() < 4 {
251            return Err(self.mismatch(format!(
252                "the Distributed table {} declares no sharding key (inserts through it \
253                 spray randomly); shard pruning requires one. Raw engine_full: {}",
254                self.display_table(),
255                row.engine_full
256            )));
257        }
258        let ddl_cluster = unquote(&args[0]);
259        if ddl_cluster != self.cluster {
260            return Err(self.mismatch(format!(
261                "the Distributed table {} is defined over cluster `{ddl_cluster}`, but \
262                 the check is configured for cluster `{}`",
263                self.display_table(),
264                self.cluster
265            )));
266        }
267        let ddl_expr = normalize(&args[3]);
268        if ddl_expr != self.expected_expr {
269            let mut msg = String::new();
270            let _ = write!(
271                msg,
272                "the sharding expression of {} does not match the sink config:\n  \
273                 DDL (normalized):      {ddl_expr}\n  \
274                 expected (normalized): {}\n  \
275                 raw engine_full:       {}",
276                self.display_table(),
277                self.expected_expr,
278                row.engine_full
279            );
280            return Err(self.mismatch(msg));
281        }
282        Ok(())
283    }
284}
285
286/// Split the top-level arguments of `Distributed(...)` out of an
287/// `engine_full` string: a quote- and paren-aware scanner (not a regex) so
288/// nested calls in the sharding expression, 5-argument policy forms, and a
289/// trailing ` SETTINGS ...` suffix all parse. Returns `None` on any shape
290/// that is not `Distributed(...)` with balanced delimiters.
291fn engine_args(engine_full: &str) -> Option<Vec<String>> {
292    let rest = engine_full.trim().strip_prefix("Distributed")?.trim_start();
293    let rest = rest.strip_prefix('(')?;
294    let mut args = Vec::new();
295    let mut cur = String::new();
296    let mut depth = 0usize;
297    let mut in_str = false;
298    let mut chars = rest.chars().peekable();
299    while let Some(c) = chars.next() {
300        if in_str {
301            cur.push(c);
302            match c {
303                '\\' => {
304                    // Backslash escape: consume the escaped char verbatim.
305                    if let Some(next) = chars.next() {
306                        cur.push(next);
307                    }
308                }
309                '\'' => {
310                    // `''` is an escaped quote, anything else ends the string.
311                    if chars.peek() == Some(&'\'') {
312                        cur.push(chars.next().expect("peeked"));
313                    } else {
314                        in_str = false;
315                    }
316                }
317                _ => {}
318            }
319            continue;
320        }
321        match c {
322            '\'' => {
323                in_str = true;
324                cur.push(c);
325            }
326            '(' => {
327                depth += 1;
328                cur.push(c);
329            }
330            ')' => {
331                if depth == 0 {
332                    // The matching close of `Distributed(` — anything after
333                    // (e.g. ` SETTINGS fsync_after_insert = 1`) is ignored.
334                    args.push(cur.trim().to_string());
335                    return Some(args);
336                }
337                depth -= 1;
338                cur.push(c);
339            }
340            ',' if depth == 0 => {
341                args.push(cur.trim().to_string());
342                cur.clear();
343            }
344            _ => cur.push(c),
345        }
346    }
347    None
348}
349
350/// Strip one level of single quotes (with `''`/`\'` unescaping) from a
351/// quoted engine argument; bare identifiers pass through trimmed.
352fn unquote(arg: &str) -> String {
353    let t = arg.trim();
354    if t.len() >= 2 && t.starts_with('\'') && t.ends_with('\'') {
355        t[1..t.len() - 1]
356            .replace("\\'", "'")
357            .replace("''", "'")
358            .replace("\\\\", "\\")
359    } else {
360        t.to_string()
361    }
362}
363
364/// Character-level expression normalization: remove ASCII whitespace and
365/// strip backtick/double-quote identifier quoting, case preserved —
366/// **outside string literals only**. Literal contents are compared
367/// verbatim (`'x y'` ≠ `'xy'`): filtering inside literals would let a
368/// drifted expression normalize equal and false-PASS the guard. Exact for
369/// the `xxHash64(identifier)` form this crate ships; the `sharding_expr`
370/// escape hatch inherits the textual (non-AST) comparison and its
371/// documented brittleness (escape style inside literals included).
372pub(crate) fn normalize(expr: &str) -> String {
373    let mut out = String::with_capacity(expr.len());
374    let mut chars = expr.chars().peekable();
375    let mut in_str = false;
376    while let Some(c) = chars.next() {
377        if in_str {
378            out.push(c);
379            match c {
380                '\\' => {
381                    // Backslash escape: keep the escaped char verbatim.
382                    if let Some(next) = chars.next() {
383                        out.push(next);
384                    }
385                }
386                '\'' => {
387                    // `''` is an escaped quote, anything else ends the string.
388                    if chars.peek() == Some(&'\'') {
389                        out.push(chars.next().expect("peeked"));
390                    } else {
391                        in_str = false;
392                    }
393                }
394                _ => {}
395            }
396            continue;
397        }
398        match c {
399            '\'' => {
400                in_str = true;
401                out.push(c);
402            }
403            c if c.is_ascii_whitespace() || c == '`' || c == '"' => {}
404            c => out.push(c),
405        }
406    }
407    out
408}
409
410/// The hostname of an `http(s)://host[:port][/...]` replica URL, for the
411/// advisory cross-check. Bracketed IPv6 hosts yield the bare address (no
412/// brackets), matching `system.clusters.host_name` formatting.
413pub(crate) fn host_of(url: &str) -> Option<String> {
414    let rest = url.split_once("://")?.1;
415    if let Some(v6) = rest.strip_prefix('[') {
416        let host = &v6[..v6.find(']')?];
417        return (!host.is_empty()).then(|| host.to_string());
418    }
419    let end = rest.find([':', '/']).unwrap_or(rest.len());
420    let host = &rest[..end];
421    (!host.is_empty()).then(|| host.to_string())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn engine_full_fourth_argument_is_the_sharding_expression() {
430        let args =
431            engine_args("Distributed('prod', 'analytics', 'events_local', xxHash64(sensor))")
432                .unwrap();
433        assert_eq!(args.len(), 4);
434        assert_eq!(unquote(&args[0]), "prod");
435        assert_eq!(unquote(&args[1]), "analytics");
436        assert_eq!(unquote(&args[2]), "events_local");
437        assert_eq!(normalize(&args[3]), "xxHash64(sensor)");
438    }
439
440    #[test]
441    fn engine_full_with_policy_arg_and_settings_suffix_still_parses() {
442        let args = engine_args(
443            "Distributed('prod', 'db', 't', xxHash64(id), 'default') \
444             SETTINGS fsync_after_insert = 1",
445        )
446        .unwrap();
447        assert_eq!(args.len(), 5);
448        assert_eq!(normalize(&args[3]), "xxHash64(id)");
449        assert_eq!(unquote(&args[4]), "default");
450    }
451
452    #[test]
453    fn engine_full_with_quoted_and_bare_arguments_unquotes() {
454        // Both AST formattings ClickHouse has used: quoted and bare.
455        for full in [
456            "Distributed('prod', 'db', 't', xxHash64(sensor))",
457            "Distributed(prod, db, t, xxHash64(sensor))",
458        ] {
459            let args = engine_args(full).unwrap();
460            assert_eq!(unquote(&args[0]), "prod", "for {full}");
461            assert_eq!(normalize(&args[3]), "xxHash64(sensor)", "for {full}");
462        }
463    }
464
465    #[test]
466    fn engine_full_without_a_sharding_key_is_parsed_as_three_arguments() {
467        let args = engine_args("Distributed('prod', 'db', 't')").unwrap();
468        assert_eq!(args.len(), 3, "no sharding key → fewer than 4 args");
469    }
470
471    #[test]
472    fn engine_full_with_nested_calls_and_quoted_commas_stays_top_level() {
473        let args =
474            engine_args("Distributed('pr,od', 'db', 't', cityHash64(concat(a, ',', b)))").unwrap();
475        assert_eq!(args.len(), 4);
476        assert_eq!(unquote(&args[0]), "pr,od", "commas inside quotes survive");
477        assert_eq!(
478            normalize(&args[3]),
479            "cityHash64(concat(a,',',b))",
480            "nested parens stay one argument"
481        );
482    }
483
484    #[test]
485    fn engine_full_that_is_not_distributed_or_unbalanced_returns_none() {
486        assert!(engine_args("MergeTree ORDER BY id").is_none());
487        assert!(engine_args("Distributed('prod', 'db', 't'").is_none());
488    }
489
490    #[test]
491    fn normalization_strips_whitespace_backticks_and_double_quotes() {
492        assert_eq!(normalize("xxHash64( `sensor` )"), "xxHash64(sensor)");
493        assert_eq!(normalize("xxHash64(\"sensor\")"), "xxHash64(sensor)");
494        assert_eq!(normalize("xxHash64(Sensor)"), "xxHash64(Sensor)");
495    }
496
497    #[test]
498    fn normalization_preserves_string_literal_contents_verbatim() {
499        // Whitespace (and quoting chars) inside a literal are significant:
500        // stripping them would let a drifted expression false-PASS the guard.
501        assert_eq!(
502            normalize("xxHash64(concat(a, 'x y'))"),
503            "xxHash64(concat(a,'x y'))"
504        );
505        assert_ne!(
506            normalize("concat(a, 'x y')"),
507            normalize("concat(a, 'xy')"),
508            "a space inside a literal must distinguish the expressions"
509        );
510        assert_ne!(
511            normalize("concat(a, '`')"),
512            normalize("concat(a, '')"),
513            "a backtick inside a literal must survive"
514        );
515        // Escapes stay verbatim; the literal ends at the right quote.
516        assert_eq!(normalize("concat('it''s', ` x `)"), "concat('it''s',x)");
517        assert_eq!(normalize(r"concat('a\' b', c )"), r"concat('a\' b',c)");
518    }
519
520    #[test]
521    fn host_of_extracts_the_hostname_from_replica_urls() {
522        assert_eq!(host_of("http://ch-0:8123").as_deref(), Some("ch-0"));
523        assert_eq!(
524            host_of("https://ch.example.com/x").as_deref(),
525            Some("ch.example.com")
526        );
527        assert_eq!(host_of("http://ch-1").as_deref(), Some("ch-1"));
528        assert_eq!(host_of("not-a-url"), None);
529    }
530
531    #[test]
532    fn host_of_unwraps_bracketed_ipv6_hosts() {
533        assert_eq!(host_of("http://[::1]:8123").as_deref(), Some("::1"));
534        assert_eq!(
535            host_of("https://[2001:db8::2]/db").as_deref(),
536            Some("2001:db8::2")
537        );
538        assert_eq!(host_of("http://[]:8123"), None, "empty brackets");
539        assert_eq!(host_of("http://[::1"), None, "unclosed bracket");
540    }
541}