1use 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#[derive(Clone, Debug, Deserialize, PartialEq)]
45#[serde(deny_unknown_fields)]
46pub struct ClickHouseSinkConfig {
47 pub table: String,
49 pub columns: Vec<String>,
52 pub shards: Vec<ShardConfig>,
56 #[serde(default)]
58 pub database: Option<String>,
59 #[serde(default)]
61 pub user: Option<String>,
62 #[serde(default)]
64 pub password: Option<String>,
65 #[serde(default)]
68 pub settings: BTreeMap<String, String>,
69 #[serde(default)]
71 pub batch: BatchConfig,
72 #[serde(default)]
74 pub inflight: InflightConfig,
75 #[serde(default)]
77 pub retry: RetryConfig,
78 #[serde(default)]
80 pub breaker: BreakerConfig,
81 #[serde(default)]
83 pub timeouts: TimeoutSection,
84 #[serde(default)]
87 pub compression: Compression,
88 #[serde(default)]
91 pub validate_schema: SchemaValidation,
92 #[serde(default)]
96 pub format: Format,
97 #[serde(default)]
101 pub distributed_check: Option<DistributedCheckSection>,
102}
103
104#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "lowercase")]
121pub enum Format {
122 #[default]
124 RowBinary,
125 Native,
127}
128
129impl Format {
130 fn keyword(self) -> &'static str {
132 match self {
133 Format::RowBinary => "RowBinary",
134 Format::Native => "Native",
135 }
136 }
137}
138
139#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "lowercase")]
149pub enum SchemaValidation {
150 #[default]
152 Off,
153 Names,
157 Full,
163}
164
165#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub enum Compression {
180 None,
182 #[default]
184 Lz4,
185 Zstd(i32),
187}
188
189const 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 let s = String::deserialize(deserializer)?;
226 s.parse().map_err(de::Error::custom)
227 }
228}
229
230fn 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#[derive(Clone, Debug, Deserialize, PartialEq)]
242#[serde(deny_unknown_fields)]
243pub struct ShardConfig {
244 pub replicas: Vec<String>,
246 #[serde(default = "default_weight")]
250 pub weight: u32,
251}
252
253fn default_weight() -> u32 {
254 1
255}
256
257#[derive(Clone, Debug, Deserialize, PartialEq)]
272#[serde(deny_unknown_fields)]
273pub struct DistributedCheckSection {
274 pub cluster: String,
276 pub table: String,
279 #[serde(default)]
282 pub sharding_key: Option<String>,
283 #[serde(default)]
287 pub sharding_expr: Option<String>,
288 #[serde(default)]
292 pub endpoint: Option<String>,
293}
294
295#[derive(Clone, Debug, Deserialize, PartialEq)]
297#[serde(deny_unknown_fields, default)]
298pub struct TimeoutSection {
299 #[serde(with = "humantime_serde")]
301 pub send: Option<Duration>,
302 #[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#[derive(Debug)]
319pub struct ClickHouseSink {
320 pub writer: ClickHouseWriter,
322 pub endpoints: Vec<Vec<ClickHouseEndpoint>>,
324 pub pool: SinkPoolConfig,
326 format: Format,
328 schema_check: schema::SchemaCheck,
330 probe_endpoints: Arc<Vec<Vec<ClickHouseEndpoint>>>,
334 shard_weights: Arc<[u32]>,
336 distributed: Option<distributed::DistributedCheck>,
338}
339
340impl ClickHouseSink {
341 pub async fn validate_schema(&self) -> Result<Option<Arc<RowSchema>>, SchemaError> {
352 schema::validate(&self.schema_check, &self.endpoints).await
353 }
354
355 #[must_use]
357 pub fn format(&self) -> Format {
358 self.format
359 }
360
361 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 #[must_use]
383 pub fn probe_fn(&self) -> SinkProbeFn {
384 endpoint_probe(self.writer.clone(), Arc::clone(&self.probe_endpoints))
385 }
386
387 #[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 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
437pub fn from_component_config(section: &ComponentConfig) -> Result<ClickHouseSink, ConfigError> {
440 let cfg: ClickHouseSinkConfig = section.deserialize_into()?;
441 build(cfg)
442}
443
444pub fn build(cfg: ClickHouseSinkConfig) -> Result<ClickHouseSink, ConfigError> {
446 validate(&cfg)?;
447
448 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 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 (§ion.sharding_key, §ion.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
525fn 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
541fn 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 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 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 if let Err(why) = cfg.retry.validate() {
620 return fail(why.to_string());
621 }
622
623 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 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
698fn 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 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 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 #[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 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}