1#![expect(deprecated, reason = "v0.4.0 removes this with spate-coordination")]
64
65mod chunk;
66mod component;
67mod coordination;
68mod error;
69mod interpolate;
70pub mod redact;
71
72pub use component::ComponentConfig;
73pub use coordination::CoordinationSection;
74pub use error::ConfigError;
75
76pub use serde_yaml::Value as YamlValue;
86
87use bytesize::ByteSize;
88use serde::Deserialize;
89use std::borrow::Cow;
90use std::collections::BTreeMap;
91use std::net::SocketAddr;
92use std::path::Path;
93use std::time::Duration;
94
95#[derive(Debug, PartialEq, Deserialize)]
104#[serde(deny_unknown_fields)]
105#[non_exhaustive]
106pub struct PipelineConfig {
107 pub pipeline: PipelineSection,
109 #[serde(default)]
111 pub admin: AdminSection,
112 #[serde(default)]
114 pub backpressure: BackpressureSection,
115 #[serde(default)]
117 pub checkpoint: CheckpointSection,
118 #[serde(default)]
120 pub metrics: MetricsSection,
121 pub source: ComponentConfig,
123 #[serde(default)]
126 pub deserializer: Option<ComponentConfig>,
127 #[serde(default)]
131 pub sink: Option<ComponentConfig>,
132 #[serde(default)]
136 pub sinks: Option<BTreeMap<String, ComponentConfig>>,
137 #[serde(default)]
140 pub coordination: Option<CoordinationSection>,
141}
142
143#[derive(Debug, PartialEq, Deserialize)]
149#[serde(deny_unknown_fields)]
150#[non_exhaustive]
151pub struct PipelineSection {
152 pub name: String,
154 #[serde(default)]
157 pub threads: Option<usize>,
158 #[serde(default = "defaults::io_threads")]
161 pub io_threads: usize,
162 #[serde(default)]
164 pub pinning: PinningMode,
165}
166
167impl PipelineSection {
168 #[must_use]
180 pub fn new(name: impl Into<String>) -> PipelineSection {
181 PipelineSection {
182 name: name.into(),
183 threads: None,
184 io_threads: defaults::io_threads(),
185 pinning: PinningMode::default(),
186 }
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193#[non_exhaustive]
194pub enum PinningMode {
195 #[default]
198 Off,
199 Compact,
201}
202
203#[derive(Debug, PartialEq, Deserialize)]
209#[serde(deny_unknown_fields, default)]
210#[non_exhaustive]
211pub struct CheckpointSection {
212 #[serde(with = "humantime_serde")]
214 pub interval: Duration,
215 pub max_pending_batches: usize,
221 #[serde(with = "humantime_serde")]
224 pub drain_timeout: Duration,
225 #[serde(with = "humantime_serde")]
233 pub stalled_fail_after: Duration,
234}
235
236impl Default for CheckpointSection {
237 fn default() -> Self {
238 CheckpointSection {
239 interval: Duration::from_secs(5),
240 max_pending_batches: 1024,
241 drain_timeout: Duration::from_secs(25),
242 stalled_fail_after: Duration::from_secs(120),
243 }
244 }
245}
246
247#[derive(Debug, PartialEq, Deserialize)]
253#[serde(deny_unknown_fields, default)]
254#[non_exhaustive]
255pub struct BackpressureSection {
256 pub max_inflight_bytes: ByteSize,
259 pub high_ratio: f64,
261 pub low_ratio: f64,
263 #[serde(with = "humantime_serde")]
266 pub min_pause: Duration,
267}
268
269impl Default for BackpressureSection {
270 fn default() -> Self {
271 BackpressureSection {
272 max_inflight_bytes: ByteSize::mib(256),
273 high_ratio: 0.8,
274 low_ratio: 0.5,
275 min_pause: Duration::from_millis(500),
276 }
277 }
278}
279
280#[derive(Debug, PartialEq, Deserialize)]
291#[serde(deny_unknown_fields, default)]
292#[non_exhaustive]
293pub struct AdminSection {
294 #[serde(deserialize_with = "listen_or_none")]
307 pub listen: Option<SocketAddr>,
308}
309
310impl Default for AdminSection {
311 fn default() -> Self {
312 AdminSection {
313 listen: Some(SocketAddr::from(([0, 0, 0, 0], 9090))),
314 }
315 }
316}
317
318fn listen_or_none<'de, D>(de: D) -> Result<Option<SocketAddr>, D::Error>
325where
326 D: serde::Deserializer<'de>,
327{
328 struct Visitor;
329
330 const EXPECTING: &str = r#"a socket address or "none""#;
331
332 impl serde::de::Visitor<'_> for Visitor {
333 type Value = Option<SocketAddr>;
334
335 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.write_str(EXPECTING)
337 }
338
339 fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
340 if text == "none" {
341 return Ok(None);
342 }
343 text.parse()
344 .map(Some)
345 .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(text), &EXPECTING))
346 }
347 }
348
349 de.deserialize_any(Visitor)
350}
351
352#[derive(Debug, PartialEq, Deserialize)]
357#[serde(deny_unknown_fields, default)]
358#[non_exhaustive]
359pub struct MetricsSection {
360 pub exporter: MetricsExporter,
362 pub per_partition_detail: bool,
365 pub e2e_basis: E2eBasis,
367}
368
369impl Default for MetricsSection {
370 fn default() -> Self {
371 MetricsSection {
372 exporter: MetricsExporter::Prometheus,
373 per_partition_detail: false,
374 e2e_basis: E2eBasis::Ingest,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
381#[serde(rename_all = "snake_case")]
382#[non_exhaustive]
383pub enum MetricsExporter {
384 #[default]
386 Prometheus,
387 None,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
394#[serde(rename_all = "snake_case")]
395#[non_exhaustive]
396pub enum E2eBasis {
397 #[default]
399 Ingest,
400 Event,
403}
404
405mod defaults {
406 pub(super) fn io_threads() -> usize {
407 2
408 }
409}
410
411fn labelled(mut component: ComponentConfig, section: &'static str) -> ComponentConfig {
414 component.set_section(section);
415 component
416}
417
418impl PipelineConfig {
419 #[must_use]
442 pub fn new(
443 pipeline: PipelineSection,
444 source: ComponentConfig,
445 sink: ComponentConfig,
446 ) -> PipelineConfig {
447 Self::assemble(pipeline, source, Some(labelled(sink, "sink")), None)
448 }
449
450 #[must_use]
478 pub fn new_multi_sink(
479 pipeline: PipelineSection,
480 source: ComponentConfig,
481 sinks: BTreeMap<String, ComponentConfig>,
482 ) -> PipelineConfig {
483 let sinks = sinks
484 .into_iter()
485 .map(|(name, sink)| (name, labelled(sink, "sink")))
486 .collect();
487 Self::assemble(pipeline, source, None, Some(sinks))
488 }
489
490 #[must_use]
510 pub fn with_deserializer(mut self, deserializer: ComponentConfig) -> PipelineConfig {
511 self.deserializer = Some(labelled(deserializer, "deserializer"));
512 self
513 }
514
515 fn assemble(
518 pipeline: PipelineSection,
519 source: ComponentConfig,
520 sink: Option<ComponentConfig>,
521 sinks: Option<BTreeMap<String, ComponentConfig>>,
522 ) -> PipelineConfig {
523 PipelineConfig {
524 pipeline,
525 admin: AdminSection::default(),
526 backpressure: BackpressureSection::default(),
527 checkpoint: CheckpointSection::default(),
528 metrics: MetricsSection::default(),
529 source: labelled(source, "source"),
530 deserializer: None,
531 sink,
532 sinks,
533 coordination: None,
534 }
535 }
536
537 #[expect(
543 clippy::should_implement_trait,
544 reason = "paired with from_path; no trait import required at call sites"
545 )]
546 pub fn from_str(text: &str) -> Result<Self, ConfigError> {
547 let interpolated = interpolate::interpolate(text)?;
548 Self::parse_interpolated(&interpolated)
549 }
550
551 pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
553 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
554 path: path.to_owned(),
555 source,
556 })?;
557 Self::from_str(&text)
558 }
559
560 fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
561 let de = serde_yaml::Deserializer::from_str(text);
562 let mut cfg: PipelineConfig = serde_path_to_error::deserialize(de).map_err(|e| {
563 let path = e.path().to_string();
564 let source = e.into_inner();
565 let source = match redact::error_message(&source.to_string()) {
566 Cow::Borrowed(_) => source,
567 Cow::Owned(message) => serde::de::Error::custom(message),
568 };
569 ConfigError::Parse { path, source }
570 })?;
571 cfg.source.set_section("source");
572 if let Some(sink) = cfg.sink.as_mut() {
573 sink.set_section("sink");
574 }
575 if let Some(sinks) = cfg.sinks.as_mut() {
576 for sink in sinks.values_mut() {
577 sink.set_section("sink");
578 }
579 }
580 if let Some(deser) = cfg.deserializer.as_mut() {
581 deser.set_section("deserializer");
582 }
583 cfg.validate()?;
584 Ok(cfg)
585 }
586
587 pub fn validate(&self) -> Result<(), ConfigError> {
591 let fail = |msg: String| Err(ConfigError::Validation(msg));
592
593 if self.pipeline.name.trim().is_empty() {
594 return fail("pipeline.name must not be empty".into());
595 }
596 if self.pipeline.io_threads == 0 {
597 return fail("pipeline.io_threads must be at least 1".into());
598 }
599 if self.pipeline.threads == Some(0) {
600 return fail("pipeline.threads must be at least 1 when set".into());
601 }
602 const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
608 if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
609 return fail(format!(
610 "checkpoint.interval must be at least 100ms (got {:?}): the commit \
611 loop fires every interval, so sub-100ms intervals hammer the \
612 source's offset store without improving durability",
613 self.checkpoint.interval
614 ));
615 }
616 if self.checkpoint.max_pending_batches == 0 {
617 return fail("checkpoint.max_pending_batches must be at least 1".into());
618 }
619 if self.checkpoint.drain_timeout.is_zero() {
620 return fail("checkpoint.drain_timeout must be greater than zero".into());
621 }
622 if self.checkpoint.stalled_fail_after.is_zero() {
623 return fail("checkpoint.stalled_fail_after must be greater than zero".into());
624 }
625 if self.backpressure.max_inflight_bytes.as_u64() == 0 {
626 return fail("backpressure.max_inflight_bytes must be greater than zero".into());
627 }
628 let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
629 if !(low > 0.0 && low < high && high <= 1.0) {
630 return fail(format!(
631 "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
632 (got low_ratio={low}, high_ratio={high})"
633 ));
634 }
635 match (&self.sink, &self.sinks) {
636 (Some(_), Some(_)) => {
637 return fail("set exactly one of `sink:` or `sinks:`, not both".into());
638 }
639 (None, None) => {
640 return fail("a `sink:` or `sinks:` section is required".into());
641 }
642 (None, Some(map)) if map.is_empty() => {
643 return fail("`sinks:` must declare at least one sink".into());
644 }
645 _ => {}
646 }
647 if let Some(sinks) = &self.sinks {
648 for name in sinks.keys() {
649 if name.is_empty() {
650 return fail("`sinks:` names must be non-empty".into());
651 }
652 if name == "sink" {
656 return fail(
657 "the sink name \"sink\" is reserved (it is the default \
658 sink's metric label); rename the `sinks:` entry"
659 .into(),
660 );
661 }
662 }
663 }
664 self.reject_stray_chunk()?;
665 if let Some(sink) = &self.sink {
669 sink.resolved_chunk()?;
670 }
671 if let Some(sinks) = &self.sinks {
672 for (name, sink) in sinks {
673 sink.resolved_chunk()
674 .map_err(|e| name_sinks_entry_error(name, e))?;
675 }
676 }
677 Ok(())
678 }
679
680 pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
688 if self.source.resolved_chunk()?.is_some() {
689 return Err(ConfigError::Validation(
690 "`chunk:` is only valid on a sink section, not `source`".into(),
691 ));
692 }
693 if let Some(deser) = &self.deserializer
694 && deser.resolved_chunk()?.is_some()
695 {
696 return Err(ConfigError::Validation(
697 "`chunk:` is only valid on a sink section, not `deserializer`".into(),
698 ));
699 }
700 Ok(())
701 }
702
703 pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
711 if let Some(sinks) = &self.sinks {
712 sinks.get(name).ok_or_else(|| {
713 let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
714 ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
715 })
716 } else if name == "default" {
717 self.sink
718 .as_ref()
719 .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
720 } else {
721 Err(ConfigError::Validation(format!(
722 "no sink named {name:?}: this pipeline configures a single `sink:` \
723 (address it as \"default\")"
724 )))
725 }
726 }
727
728 #[must_use]
731 pub fn sink_names(&self) -> Vec<String> {
732 match &self.sinks {
733 Some(sinks) => sinks.keys().cloned().collect(),
734 None => vec!["default".to_string()],
735 }
736 }
737}
738
739fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
743 match e {
744 ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
745 ConfigError::Component { context, message } => ConfigError::Component {
746 context: match context.strip_prefix("sink.") {
747 Some(rest) => format!("sinks.{name}.{rest}"),
748 None => format!("sinks.{name}.{context}"),
749 },
750 message,
751 },
752 other => other,
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 const MINIMAL: &str = r#"
761pipeline: { name: demo }
762source: { memory: {} }
763sink: { memory: {} }
764"#;
765
766 #[test]
768 fn pipeline_config_debug_never_prints_component_values() {
769 let cfg = PipelineConfig::from_str(
770 "pipeline: { name: demo }\n\
771 source: { kafka: { rdkafka: { sasl.password: hunter2 } } }\n\
772 deserializer: { avro: { registry: { url: https://sr, password: hunter2 } } }\n\
773 sink: { clickhouse: { password: hunter2 } }\n",
774 )
775 .unwrap();
776 let printed = format!("{cfg:?}");
777 assert!(!printed.contains("hunter2"), "{printed}");
778 assert!(printed.contains("sasl.password"), "{printed}");
779 }
780
781 #[test]
782 fn minimal_config_applies_documented_defaults() {
783 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
784 assert_eq!(cfg.pipeline.name, "demo");
785 assert_eq!(cfg.pipeline.threads, None);
786 assert_eq!(cfg.pipeline.io_threads, 2);
787 assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
788 assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
789 assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
790 assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
791 assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
792 assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
793 assert_eq!(cfg.backpressure.high_ratio, 0.8);
794 assert_eq!(cfg.backpressure.low_ratio, 0.5);
795 assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
796 assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
797 assert!(!cfg.metrics.per_partition_detail);
798 assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
799 assert_eq!(
800 cfg.admin.listen,
801 Some(SocketAddr::from(([0, 0, 0, 0], 9090)))
802 );
803 assert!(cfg.deserializer.is_none());
804 }
805
806 #[test]
809 fn new_matches_the_yaml_defaults() {
810 let body = || YamlValue::Mapping(Default::default());
811 assert_eq!(
812 PipelineConfig::new(
813 PipelineSection::new("demo"),
814 ComponentConfig::new("memory", body()),
815 ComponentConfig::new("memory", body()),
816 ),
817 PipelineConfig::from_str(MINIMAL).unwrap()
818 );
819 }
820
821 #[test]
824 fn new_multi_sink_matches_the_yaml_defaults() {
825 let body = || YamlValue::Mapping(Default::default());
826 let sinks = BTreeMap::from([
827 ("eu".to_owned(), ComponentConfig::new("memory", body())),
828 ("us".to_owned(), ComponentConfig::new("memory", body())),
829 ]);
830 let yaml = "
831pipeline: { name: demo }
832source: { memory: {} }
833sinks:
834 eu: { memory: {} }
835 us: { memory: {} }
836";
837 assert_eq!(
838 PipelineConfig::new_multi_sink(
839 PipelineSection::new("demo"),
840 ComponentConfig::new("memory", body()),
841 sinks,
842 ),
843 PipelineConfig::from_str(yaml).unwrap()
844 );
845 }
846
847 #[test]
851 fn with_deserializer_matches_the_yaml_form() {
852 let body = || YamlValue::Mapping(Default::default());
853 let yaml = "
854pipeline: { name: demo }
855source: { memory: {} }
856deserializer: { json: {} }
857sink: { memory: {} }
858";
859 assert_eq!(
860 PipelineConfig::new(
861 PipelineSection::new("demo"),
862 ComponentConfig::new("memory", body()),
863 ComponentConfig::new("memory", body()),
864 )
865 .with_deserializer(ComponentConfig::new("json", body())),
866 PipelineConfig::from_str(yaml).unwrap()
867 );
868 }
869
870 #[test]
873 fn a_constructed_config_validates() {
874 let body = || YamlValue::Mapping(Default::default());
875 PipelineConfig::new(
876 PipelineSection::new("demo"),
877 ComponentConfig::new("memory", body()),
878 ComponentConfig::new("memory", body()),
879 )
880 .validate()
881 .expect("the single-sink form validates");
882
883 PipelineConfig::new_multi_sink(
884 PipelineSection::new("demo"),
885 ComponentConfig::new("memory", body()),
886 BTreeMap::from([("eu".to_owned(), ComponentConfig::new("memory", body()))]),
887 )
888 .validate()
889 .expect("the multi-sink form validates");
890 }
891
892 #[test]
893 fn admin_listen_takes_an_address_or_none() {
894 let with = |admin: &str| {
895 PipelineConfig::from_str(&format!(
896 "pipeline: {{ name: demo }}\n{admin}\nsource: {{ memory: {{}} }}\n\
897 sink: {{ memory: {{}} }}\n"
898 ))
899 };
900
901 let bound = with("admin: { listen: 127.0.0.1:7777 }").expect("an address parses");
902 assert_eq!(
903 bound.admin.listen,
904 Some(SocketAddr::from(([127, 0, 0, 1], 7777)))
905 );
906
907 let off = with("admin: { listen: none }").expect("`none` parses");
908 assert_eq!(off.admin.listen, None, "`none` asks for no server");
909
910 let err = with("admin: { listen: 9090 }").expect_err("a bare port is not an address");
913 let msg = err.to_string();
914 assert!(msg.contains("admin.listen"), "{msg}");
915 assert!(msg.contains(r#"a socket address or "none""#), "{msg}");
916
917 for null in [
921 "admin: { listen: ~ }",
922 "admin: { listen: null }",
923 "admin:\n listen:",
924 ] {
925 let msg = with(null)
926 .expect_err("a null is not an address")
927 .to_string();
928 assert!(
929 msg.contains(r#"a socket address or "none""#),
930 "{null}: {msg}"
931 );
932 }
933 }
934
935 #[test]
939 fn the_metrics_section_takes_no_bind_address() {
940 let err = PipelineConfig::from_str(
941 "pipeline: { name: demo }\nmetrics: { listen: 0.0.0.0:9090 }\n\
942 source: { memory: {} }\nsink: { memory: {} }\n",
943 )
944 .expect_err("metrics carries no bind address");
945 let msg = err.to_string();
946 assert!(msg.contains("listen"), "{msg}");
947 }
948
949 #[test]
950 fn sink_chunk_block_parses_and_resolves() {
951 let yaml = r#"
952pipeline: { name: demo }
953source: { memory: {} }
954sink:
955 memory:
956 chunk: { target_bytes: 512KiB, encode_policy: fail }
957"#;
958 let cfg = PipelineConfig::from_str(yaml).unwrap();
959 let chunk = cfg
960 .sink_config("default")
961 .unwrap()
962 .resolved_chunk()
963 .unwrap()
964 .expect("chunk present");
965 assert_eq!(chunk.target_bytes, 512 * 1024);
966 assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
967 }
968
969 #[test]
970 fn chunk_on_a_source_section_is_rejected() {
971 let yaml = r#"
972pipeline: { name: demo }
973source:
974 memory:
975 chunk: { target_bytes: 64KiB }
976sink: { memory: {} }
977"#;
978 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
979 assert!(err.contains("chunk"), "{err}");
980 assert!(err.contains("source"), "{err}");
981 }
982
983 #[test]
984 fn coordination_section_is_optional_and_parsed_when_present() {
985 let base = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n";
986 assert!(
987 PipelineConfig::from_str(base)
988 .unwrap()
989 .coordination
990 .is_none()
991 );
992
993 let yaml =
994 format!("{base}coordination:\n lease_duration: 10s\n store: {{ nats: {{}} }}\n");
995 let cfg = PipelineConfig::from_str(&yaml).unwrap();
996 let section = cfg.coordination.expect("section");
997 assert_eq!(section.store().type_tag(), "nats");
998
999 let yaml = format!("{base}coordination: {{ lease_duration: 10s }}\n");
1000 let err = PipelineConfig::from_str(&yaml).unwrap_err().to_string();
1001 assert!(err.contains("coordination"), "{err}");
1002 assert!(err.contains("store"), "{err}");
1003 }
1004
1005 #[test]
1006 fn zero_target_bytes_in_yaml_is_rejected_at_load() {
1007 let yaml = r#"
1008pipeline: { name: demo }
1009source: { memory: {} }
1010sink:
1011 memory:
1012 chunk: { target_bytes: 0B }
1013"#;
1014 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1017 assert!(err.contains("chunk.target_bytes"), "{err}");
1018 }
1019
1020 #[test]
1021 fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
1022 let yaml = r#"
1026pipeline: { name: demo }
1027source: { memory: {} }
1028sinks:
1029 hot: { memory: {} }
1030 cold:
1031 memory:
1032 chunk: { encode_policy: retry }
1033"#;
1034 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1035 assert!(err.contains("sinks.cold"), "{err}");
1036 let yaml = r#"
1037pipeline: { name: demo }
1038source: { memory: {} }
1039sinks:
1040 cold:
1041 memory:
1042 chunk: { target_bytes: 0B }
1043"#;
1044 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1045 assert!(err.contains("sinks.cold"), "{err}");
1046 assert!(err.contains("chunk.target_bytes"), "{err}");
1047 }
1048
1049 #[test]
1050 fn full_design_doc_example_parses() {
1051 let yaml = r#"
1055pipeline: { name: orders, threads: 4, io_threads: 2 }
1056checkpoint: { interval: 5s, max_pending_batches: 1024 }
1057backpressure: { max_inflight_bytes: 256MiB }
1058source:
1059 kafka:
1060 brokers: ${KAFKA_BROKERS:-localhost:9092}
1061 topic: orders
1062 group_id: orders-etl
1063 rdkafka: { fetch.message.max.bytes: "1048576" }
1064deserializer:
1065 avro:
1066 mode: confluent
1067 registry:
1068 url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
1069sink:
1070 clickhouse:
1071 table: orders_local
1072 shards:
1073 - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
1074 - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
1075 batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
1076 inflight: { max_per_shard: 2 }
1077 retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
1078admin: { listen: 0.0.0.0:9090 }
1079metrics: { exporter: prometheus }
1080"#;
1081 let cfg = PipelineConfig::from_str(yaml).unwrap();
1082 assert_eq!(cfg.pipeline.threads, Some(4));
1083 assert_eq!(cfg.source.type_tag(), "kafka");
1084 assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
1085 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
1086
1087 #[derive(Debug, serde::Deserialize)]
1090 struct KafkaProbe {
1091 brokers: String,
1092 group_id: String,
1093 #[serde(flatten)]
1094 _rest: serde_yaml::Value,
1095 }
1096 let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
1097 assert_eq!(kafka.brokers, "localhost:9092");
1098 assert_eq!(kafka.group_id, "orders-etl");
1099
1100 #[derive(Debug, serde::Deserialize)]
1103 struct AvroProbe {
1104 registry: RegistryProbe,
1105 }
1106 #[derive(Debug, serde::Deserialize)]
1107 struct RegistryProbe {
1108 url: String,
1109 }
1110 let avro: AvroProbe = cfg
1111 .deserializer
1112 .as_ref()
1113 .unwrap()
1114 .deserialize_into()
1115 .unwrap();
1116 assert_eq!(avro.registry.url, "http://sr:8081");
1117
1118 #[derive(Debug, serde::Deserialize)]
1119 struct ChProbe {
1120 shards: Vec<ShardProbe>,
1121 }
1122 #[derive(Debug, serde::Deserialize)]
1123 struct ShardProbe {
1124 replicas: Vec<String>,
1125 }
1126 let ch: ChProbe = cfg
1127 .sink_config("default")
1128 .unwrap()
1129 .deserialize_into()
1130 .unwrap();
1131 assert_eq!(
1132 ch.shards[0].replicas,
1133 ["http://ch-0-0:8123", "http://ch-0-1:8123"]
1134 );
1135 }
1136
1137 #[test]
1138 fn single_sink_resolves_as_default() {
1139 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
1140 assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
1141 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
1142 assert!(cfg.sink_config("other").is_err());
1143 }
1144
1145 #[test]
1146 fn sinks_map_parses_and_resolves_by_name() {
1147 let yaml = r#"
1148pipeline: { name: demo }
1149source: { memory: {} }
1150sinks:
1151 type_a: { memory: {} }
1152 type_b: { memory: {} }
1153"#;
1154 let cfg = PipelineConfig::from_str(yaml).unwrap();
1155 assert_eq!(
1156 cfg.sink_names(),
1157 vec!["type_a".to_string(), "type_b".to_string()]
1158 );
1159 assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
1160 assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
1161 assert!(cfg.sink_config("default").is_err());
1163 }
1164
1165 #[test]
1166 fn sink_and_sinks_are_mutually_exclusive() {
1167 let both = r#"
1168pipeline: { name: demo }
1169source: { memory: {} }
1170sink: { memory: {} }
1171sinks:
1172 a: { memory: {} }
1173"#;
1174 assert!(matches!(
1175 PipelineConfig::from_str(both),
1176 Err(ConfigError::Validation(_))
1177 ));
1178
1179 let neither = r#"
1180pipeline: { name: demo }
1181source: { memory: {} }
1182"#;
1183 assert!(matches!(
1184 PipelineConfig::from_str(neither),
1185 Err(ConfigError::Validation(_))
1186 ));
1187
1188 let empty = r#"
1189pipeline: { name: demo }
1190source: { memory: {} }
1191sinks: {}
1192"#;
1193 assert!(matches!(
1194 PipelineConfig::from_str(empty),
1195 Err(ConfigError::Validation(_))
1196 ));
1197 }
1198
1199 #[test]
1200 fn reserved_sink_names_are_rejected() {
1201 let reserved = r#"
1204pipeline: { name: demo }
1205source: { memory: {} }
1206sinks:
1207 sink: { memory: {} }
1208"#;
1209 assert!(matches!(
1210 PipelineConfig::from_str(reserved),
1211 Err(ConfigError::Validation(msg)) if msg.contains("reserved")
1212 ));
1213
1214 let empty_name = r#"
1215pipeline: { name: demo }
1216source: { memory: {} }
1217sinks:
1218 "": { memory: {} }
1219"#;
1220 assert!(matches!(
1221 PipelineConfig::from_str(empty_name),
1222 Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
1223 ));
1224 }
1225
1226 #[test]
1227 fn unknown_fields_are_rejected_at_every_typed_level() {
1228 for (yaml, field) in [
1229 (
1230 "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
1231 "bogus",
1232 ),
1233 (
1234 "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
1235 "intervall",
1236 ),
1237 (
1238 "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
1239 "port",
1240 ),
1241 (
1242 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
1243 "sinks",
1244 ),
1245 ] {
1246 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1247 assert!(err.contains(field), "expected `{field}` in error: {err}");
1248 }
1249 }
1250
1251 #[test]
1252 fn parse_errors_carry_the_yaml_path() {
1253 let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
1254 let err = PipelineConfig::from_str(yaml).unwrap_err();
1255 let text = err.to_string();
1256 assert!(text.contains("pipeline.io_threads"), "{text}");
1257 }
1258
1259 #[test]
1263 fn parse_errors_omit_the_value() {
1264 for (yaml, path, expected) in [
1265 (
1266 "pipeline: { name: x }\ncheckpoint: { interval: hunter2 }\nsource: { m: {} }\nsink: { m: {} }",
1267 "checkpoint.interval",
1268 "expected a duration at line 2 column",
1269 ),
1270 (
1271 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: { password: !!int hunter2 } }",
1272 "sink.m.password",
1273 "expected an integer at line 3 column",
1274 ),
1275 ] {
1276 let err = PipelineConfig::from_str(yaml).unwrap_err();
1277 for shown in [err.to_string(), format!("{err:?}")] {
1278 assert!(!shown.contains("hunter2"), "{shown}");
1279 assert!(shown.contains(path), "{shown}");
1280 assert!(shown.contains(expected), "{shown}");
1281 }
1282 }
1283 }
1284
1285 #[test]
1286 fn validation_rules() {
1287 let cases = [
1288 (
1289 "pipeline: { name: ' ' }\nsource: { m: {} }\nsink: { m: {} }",
1290 "pipeline.name",
1291 ),
1292 (
1293 "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1294 "io_threads",
1295 ),
1296 (
1297 "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1298 "threads",
1299 ),
1300 (
1301 "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1302 "interval",
1303 ),
1304 (
1305 "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
1306 "at least 100ms",
1307 ),
1308 (
1309 "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1310 "max_pending_batches",
1311 ),
1312 (
1313 "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1314 "drain_timeout",
1315 ),
1316 (
1317 "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1318 "stalled_fail_after",
1319 ),
1320 (
1321 "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1322 "max_inflight_bytes",
1323 ),
1324 (
1325 "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
1326 "low_ratio",
1327 ),
1328 (
1329 "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
1330 "high_ratio",
1331 ),
1332 ];
1333 for (yaml, needle) in cases {
1334 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1335 assert!(err.contains(needle), "expected `{needle}` in: {err}");
1336 }
1337 }
1338
1339 #[test]
1340 fn missing_required_sections_error_clearly() {
1341 let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
1342 .unwrap_err()
1343 .to_string();
1344 assert!(err.contains("source"), "{err}");
1345 let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
1346 .unwrap_err()
1347 .to_string();
1348 assert!(err.contains("pipeline"), "{err}");
1349 }
1350
1351 #[test]
1352 fn interpolation_failures_surface_with_position() {
1353 let err = PipelineConfig::from_str("pipeline:\n name: ${UNSET_VAR_FOR_TEST}\n")
1354 .unwrap_err()
1355 .to_string();
1356 assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
1357 assert!(err.contains("line 2"), "{err}");
1358 }
1359
1360 #[test]
1361 fn from_path_reads_interpolates_and_reports_io_errors() {
1362 use std::io::Write as _;
1363 let mut file = tempfile::NamedTempFile::new().unwrap();
1364 write!(
1365 file,
1366 "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
1367 )
1368 .unwrap();
1369 let cfg = PipelineConfig::from_path(file.path()).unwrap();
1370 assert_eq!(cfg.pipeline.name, "from-file");
1371
1372 let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
1373 assert!(matches!(err, ConfigError::Io { .. }));
1374 assert!(err.to_string().contains("/nonexistent/spate.yaml"));
1375 }
1376
1377 #[test]
1378 fn equal_inputs_parse_to_equal_configs() {
1379 let a = PipelineConfig::from_str(MINIMAL).unwrap();
1380 let b = PipelineConfig::from_str(MINIMAL).unwrap();
1381 assert_eq!(a, b);
1382 let c = PipelineConfig::from_str(
1383 "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
1384 )
1385 .unwrap();
1386 assert_ne!(a, c);
1387 }
1388}