1use crate::writer::ClickHouseEndpoint;
19use serde::Deserialize;
20use std::fmt::Write as _;
21use std::sync::Arc;
22
23#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum DistributedCheckError {
27 #[error("sink.clickhouse.distributed_check: could not fetch {what} from {url}: {reason}")]
31 Fetch {
32 what: &'static str,
34 url: String,
36 reason: String,
38 },
39 #[error("{0}")]
41 Mismatch(String),
42}
43
44#[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#[derive(Clone, Debug, clickhouse::Row, Deserialize)]
54pub(crate) struct TableEngineRow {
55 pub(crate) engine: String,
56 pub(crate) engine_full: String,
57}
58
59#[derive(Debug)]
61pub(crate) struct DistributedCheck {
62 pub(crate) endpoint: ClickHouseEndpoint,
65 pub(crate) cluster: String,
67 pub(crate) database: Option<String>,
69 pub(crate) table: String,
71 pub(crate) expected_expr: String,
73 pub(crate) weights: Arc<[u32]>,
75 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 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 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 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 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 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
286fn 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 if let Some(next) = chars.next() {
306 cur.push(next);
307 }
308 }
309 '\'' => {
310 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 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
350fn 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
364pub(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 if let Some(next) = chars.next() {
383 out.push(next);
384 }
385 }
386 '\'' => {
387 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
410pub(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 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 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 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}