1mod chunk;
64mod component;
65mod error;
66mod feed;
67mod interpolate;
68pub mod redact;
69
70pub use component::ComponentConfig;
71pub use error::ConfigError;
72pub use feed::FeedSection;
73
74pub use serde_yaml::Value as YamlValue;
84
85use bytesize::ByteSize;
86use humantime_serde::re::humantime::format_duration;
87use serde::Deserialize;
88use std::borrow::Cow;
89use std::collections::BTreeMap;
90use std::net::SocketAddr;
91use std::path::Path;
92use std::time::Duration;
93
94#[derive(Debug, PartialEq, Deserialize)]
103#[serde(deny_unknown_fields)]
104#[non_exhaustive]
105pub struct PipelineConfig {
106 pub pipeline: PipelineSection,
108 #[serde(default)]
110 pub admin: AdminSection,
111 #[serde(default)]
113 pub backpressure: BackpressureSection,
114 #[serde(default)]
116 pub checkpoint: CheckpointSection,
117 #[serde(default)]
119 pub metrics: MetricsSection,
120 pub source: ComponentConfig,
122 #[serde(default)]
125 pub deserializer: Option<ComponentConfig>,
126 #[serde(default)]
130 pub sink: Option<ComponentConfig>,
131 #[serde(default)]
135 pub sinks: Option<BTreeMap<String, ComponentConfig>>,
136 #[serde(default)]
139 pub feed: Option<FeedSection>,
140}
141
142#[derive(Debug, PartialEq, Deserialize)]
148#[serde(deny_unknown_fields)]
149#[non_exhaustive]
150pub struct PipelineSection {
151 pub name: String,
153 #[serde(default)]
157 pub threads: Option<usize>,
158 #[serde(default = "defaults::io_threads")]
163 pub io_threads: usize,
164 #[serde(default)]
166 pub pinning: PinningMode,
167}
168
169impl PipelineSection {
170 #[must_use]
182 pub fn new(name: impl Into<String>) -> PipelineSection {
183 PipelineSection {
184 name: name.into(),
185 threads: None,
186 io_threads: defaults::io_threads(),
187 pinning: PinningMode::default(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum PinningMode {
197 #[default]
200 Off,
201 Compact,
203}
204
205#[derive(Debug, PartialEq, Deserialize)]
211#[serde(deny_unknown_fields, default)]
212#[non_exhaustive]
213pub struct CheckpointSection {
214 #[serde(with = "humantime_serde")]
216 pub interval: Duration,
217 pub max_pending_batches: usize,
223 #[serde(with = "humantime_serde")]
228 pub drain_timeout: Duration,
229 #[serde(with = "humantime_serde")]
237 pub stalled_fail_after: Duration,
238}
239
240impl Default for CheckpointSection {
241 fn default() -> Self {
242 CheckpointSection {
243 interval: Duration::from_secs(5),
244 max_pending_batches: 1024,
245 drain_timeout: Duration::from_secs(25),
246 stalled_fail_after: Duration::from_secs(120),
247 }
248 }
249}
250
251#[derive(Debug, PartialEq, Deserialize)]
257#[serde(deny_unknown_fields, default)]
258#[non_exhaustive]
259pub struct BackpressureSection {
260 pub max_inflight_bytes: ByteSize,
263 pub high_ratio: f64,
265 pub low_ratio: f64,
267 #[serde(with = "humantime_serde")]
270 pub min_pause: Duration,
271}
272
273impl Default for BackpressureSection {
274 fn default() -> Self {
275 BackpressureSection {
276 max_inflight_bytes: ByteSize::mib(256),
277 high_ratio: 0.8,
278 low_ratio: 0.5,
279 min_pause: Duration::from_millis(500),
280 }
281 }
282}
283
284#[derive(Debug, PartialEq, Deserialize)]
295#[serde(deny_unknown_fields, default)]
296#[non_exhaustive]
297pub struct AdminSection {
298 #[serde(deserialize_with = "listen_or_none")]
311 pub listen: Option<SocketAddr>,
312}
313
314impl Default for AdminSection {
315 fn default() -> Self {
316 AdminSection {
317 listen: Some(SocketAddr::from(([0, 0, 0, 0], 9090))),
318 }
319 }
320}
321
322fn listen_or_none<'de, D>(de: D) -> Result<Option<SocketAddr>, D::Error>
329where
330 D: serde::Deserializer<'de>,
331{
332 struct Visitor;
333
334 const EXPECTING: &str = r#"a socket address or "none""#;
335
336 impl serde::de::Visitor<'_> for Visitor {
337 type Value = Option<SocketAddr>;
338
339 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 f.write_str(EXPECTING)
341 }
342
343 fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
344 if text == "none" {
345 return Ok(None);
346 }
347 text.parse()
348 .map(Some)
349 .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(text), &EXPECTING))
350 }
351 }
352
353 de.deserialize_any(Visitor)
354}
355
356#[derive(Debug, PartialEq, Deserialize)]
361#[serde(deny_unknown_fields, default)]
362#[non_exhaustive]
363pub struct MetricsSection {
364 pub exporter: MetricsExporter,
366 pub per_partition_detail: bool,
369 pub e2e_basis: E2eBasis,
371}
372
373impl Default for MetricsSection {
374 fn default() -> Self {
375 MetricsSection {
376 exporter: MetricsExporter::Prometheus,
377 per_partition_detail: false,
378 e2e_basis: E2eBasis::Ingest,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
385#[serde(rename_all = "snake_case")]
386#[non_exhaustive]
387pub enum MetricsExporter {
388 #[default]
390 Prometheus,
391 None,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
398#[serde(rename_all = "snake_case")]
399#[non_exhaustive]
400pub enum E2eBasis {
401 #[default]
403 Ingest,
404 Event,
407}
408
409mod defaults {
410 pub(super) fn io_threads() -> usize {
411 2
412 }
413}
414
415fn labelled(mut component: ComponentConfig, section: &'static str) -> ComponentConfig {
418 component.set_section(section);
419 component
420}
421
422impl PipelineConfig {
423 #[must_use]
446 pub fn new(
447 pipeline: PipelineSection,
448 source: ComponentConfig,
449 sink: ComponentConfig,
450 ) -> PipelineConfig {
451 Self::assemble(pipeline, source, Some(labelled(sink, "sink")), None)
452 }
453
454 #[must_use]
482 pub fn new_multi_sink(
483 pipeline: PipelineSection,
484 source: ComponentConfig,
485 sinks: BTreeMap<String, ComponentConfig>,
486 ) -> PipelineConfig {
487 let sinks = sinks
488 .into_iter()
489 .map(|(name, sink)| (name, labelled(sink, "sink")))
490 .collect();
491 Self::assemble(pipeline, source, None, Some(sinks))
492 }
493
494 #[must_use]
514 pub fn with_deserializer(mut self, deserializer: ComponentConfig) -> PipelineConfig {
515 self.deserializer = Some(labelled(deserializer, "deserializer"));
516 self
517 }
518
519 fn assemble(
522 pipeline: PipelineSection,
523 source: ComponentConfig,
524 sink: Option<ComponentConfig>,
525 sinks: Option<BTreeMap<String, ComponentConfig>>,
526 ) -> PipelineConfig {
527 PipelineConfig {
528 pipeline,
529 admin: AdminSection::default(),
530 backpressure: BackpressureSection::default(),
531 checkpoint: CheckpointSection::default(),
532 metrics: MetricsSection::default(),
533 source: labelled(source, "source"),
534 deserializer: None,
535 sink,
536 sinks,
537 feed: None,
538 }
539 }
540
541 #[expect(
547 clippy::should_implement_trait,
548 reason = "paired with from_path; no trait import required at call sites"
549 )]
550 pub fn from_str(text: &str) -> Result<Self, ConfigError> {
551 let interpolated = interpolate::interpolate(text)?;
552 Self::parse_interpolated(&interpolated)
553 }
554
555 pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
557 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
558 path: path.to_owned(),
559 source,
560 })?;
561 Self::from_str(&text)
562 }
563
564 fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
565 let de = serde_yaml::Deserializer::from_str(text);
566 let mut cfg: PipelineConfig = serde_path_to_error::deserialize(de).map_err(|e| {
567 let path = e.path().to_string();
568 let source = e.into_inner();
569 let source = match redact::error_message(&source.to_string()) {
570 Cow::Borrowed(_) => source,
571 Cow::Owned(message) => serde::de::Error::custom(message),
572 };
573 ConfigError::Parse { path, source }
574 })?;
575 cfg.source.set_section("source");
576 if let Some(sink) = cfg.sink.as_mut() {
577 sink.set_section("sink");
578 }
579 if let Some(sinks) = cfg.sinks.as_mut() {
580 for sink in sinks.values_mut() {
581 sink.set_section("sink");
582 }
583 }
584 if let Some(deser) = cfg.deserializer.as_mut() {
585 deser.set_section("deserializer");
586 }
587 cfg.validate()?;
588 Ok(cfg)
589 }
590
591 pub fn validate(&self) -> Result<(), ConfigError> {
595 let fail = |msg: String| Err(ConfigError::Validation(msg));
596
597 if self.pipeline.name.trim().is_empty() {
598 return fail("pipeline.name must not be empty".into());
599 }
600 if self.pipeline.io_threads == 0 {
601 return fail("pipeline.io_threads must be at least 1".into());
602 }
603 if self.pipeline.threads == Some(0) {
604 return fail("pipeline.threads must be at least 1 when set".into());
605 }
606 const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
613 if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
614 return fail(format!(
615 "checkpoint.interval must be at least 100ms (got {:?}): the commit \
616 loop fires every interval, so sub-100ms intervals hammer the \
617 source's offset store without improving durability",
618 self.checkpoint.interval
619 ));
620 }
621 if self.checkpoint.max_pending_batches == 0 {
622 return fail("checkpoint.max_pending_batches must be at least 1".into());
623 }
624 if self.checkpoint.drain_timeout.is_zero() {
625 return fail("checkpoint.drain_timeout must be greater than zero".into());
626 }
627 if self.checkpoint.stalled_fail_after.is_zero() {
628 return fail("checkpoint.stalled_fail_after must be greater than zero".into());
629 }
630 if self.backpressure.max_inflight_bytes.as_u64() == 0 {
631 return fail("backpressure.max_inflight_bytes must be greater than zero".into());
632 }
633 let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
634 if !(low > 0.0 && low < high && high <= 1.0) {
635 return fail(format!(
636 "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
637 (got low_ratio={low}, high_ratio={high})"
638 ));
639 }
640 match (&self.sink, &self.sinks) {
641 (Some(_), Some(_)) => {
642 return fail("set exactly one of `sink:` or `sinks:`, not both".into());
643 }
644 (None, None) => {
645 return fail("a `sink:` or `sinks:` section is required".into());
646 }
647 (None, Some(map)) if map.is_empty() => {
648 return fail("`sinks:` must declare at least one sink".into());
649 }
650 _ => {}
651 }
652 if let Some(sinks) = &self.sinks {
653 for name in sinks.keys() {
654 if name.is_empty() {
655 return fail("`sinks:` names must be non-empty".into());
656 }
657 if name == "sink" {
661 return fail(
662 "the sink name \"sink\" is reserved (it is the default \
663 sink's metric label); rename the `sinks:` entry"
664 .into(),
665 );
666 }
667 }
668 }
669 self.reject_stray_chunk()?;
670 self.reject_location_on_memory()?;
671 self.reject_bad_settling_window()?;
672 if let Some(sink) = &self.sink {
676 sink.resolved_chunk()?;
677 }
678 if let Some(sinks) = &self.sinks {
679 for (name, sink) in sinks {
680 sink.resolved_chunk()
681 .map_err(|e| name_sinks_entry_error(name, e))?;
682 }
683 }
684 Ok(())
685 }
686
687 pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
695 if self.source.resolved_chunk()?.is_some() {
696 return Err(ConfigError::Validation(
697 "`chunk:` is only valid on a sink section, not `source`".into(),
698 ));
699 }
700 if let Some(deser) = &self.deserializer
701 && deser.resolved_chunk()?.is_some()
702 {
703 return Err(ConfigError::Validation(
704 "`chunk:` is only valid on a sink section, not `deserializer`".into(),
705 ));
706 }
707 if let Some(feed) = &self.feed
708 && feed.backend().resolved_chunk()?.is_some()
709 {
710 return Err(ConfigError::Validation(
711 "`chunk:` is only valid on a sink section, not `feed`".into(),
712 ));
713 }
714 Ok(())
715 }
716
717 pub(crate) fn reject_location_on_memory(&self) -> Result<(), ConfigError> {
722 if let Some(feed) = &self.feed
723 && feed.location.is_some()
724 && feed.backend().type_tag() == "memory"
725 {
726 return Err(ConfigError::Validation(
727 "the in-process `memory` feed takes no `feed.location`; remove it".into(),
728 ));
729 }
730 Ok(())
731 }
732
733 pub(crate) fn reject_bad_settling_window(&self) -> Result<(), ConfigError> {
740 const CEILING: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
741 let Some(feed) = &self.feed else {
742 return Ok(());
743 };
744 let Some(window) = feed.settling_window else {
745 return Ok(());
746 };
747 if feed.backend().type_tag() == "memory" {
748 return Err(ConfigError::Validation(
749 "the in-process `memory` feed counts its units exactly and takes no settling \
750 window; remove `feed.settling_window`"
751 .into(),
752 ));
753 }
754 let floor = crate::feed::END_CHECK_INTERVAL;
755 if window < floor || window > CEILING {
756 return Err(ConfigError::Validation(format!(
757 "`feed.settling_window` must be from {} to {}; found {}",
758 format_duration(floor),
759 format_duration(CEILING),
760 format_duration(window)
761 )));
762 }
763 Ok(())
764 }
765
766 pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
774 if let Some(sinks) = &self.sinks {
775 sinks.get(name).ok_or_else(|| {
776 let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
777 ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
778 })
779 } else if name == "default" {
780 self.sink
781 .as_ref()
782 .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
783 } else {
784 Err(ConfigError::Validation(format!(
785 "no sink named {name:?}: this pipeline configures a single `sink:` \
786 (address it as \"default\")"
787 )))
788 }
789 }
790
791 #[must_use]
794 pub fn sink_names(&self) -> Vec<String> {
795 match &self.sinks {
796 Some(sinks) => sinks.keys().cloned().collect(),
797 None => vec!["default".to_string()],
798 }
799 }
800}
801
802fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
806 match e {
807 ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
808 ConfigError::Component { context, message } => ConfigError::Component {
809 context: match context.strip_prefix("sink.") {
810 Some(rest) => format!("sinks.{name}.{rest}"),
811 None => format!("sinks.{name}.{context}"),
812 },
813 message,
814 },
815 other => other,
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 const MINIMAL: &str = r#"
824pipeline: { name: demo }
825source: { memory: {} }
826sink: { memory: {} }
827"#;
828
829 #[test]
831 fn pipeline_config_debug_never_prints_component_values() {
832 let cfg = PipelineConfig::from_str(
833 "pipeline: { name: demo }\n\
834 source: { kafka: { rdkafka: { sasl.password: hunter2 } } }\n\
835 deserializer: { avro: { registry: { url: https://sr, password: hunter2 } } }\n\
836 sink: { clickhouse: { password: hunter2 } }\n",
837 )
838 .unwrap();
839 let printed = format!("{cfg:?}");
840 assert!(!printed.contains("hunter2"), "{printed}");
841 assert!(printed.contains("sasl.password"), "{printed}");
842 }
843
844 #[test]
845 fn minimal_config_applies_documented_defaults() {
846 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
847 assert_eq!(cfg.pipeline.name, "demo");
848 assert_eq!(cfg.pipeline.threads, None);
849 assert_eq!(cfg.pipeline.io_threads, 2);
850 assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
851 assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
852 assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
853 assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
854 assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
855 assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
856 assert_eq!(cfg.backpressure.high_ratio, 0.8);
857 assert_eq!(cfg.backpressure.low_ratio, 0.5);
858 assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
859 assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
860 assert!(!cfg.metrics.per_partition_detail);
861 assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
862 assert_eq!(
863 cfg.admin.listen,
864 Some(SocketAddr::from(([0, 0, 0, 0], 9090)))
865 );
866 assert!(cfg.deserializer.is_none());
867 }
868
869 #[test]
872 fn new_matches_the_yaml_defaults() {
873 let body = || YamlValue::Mapping(Default::default());
874 assert_eq!(
875 PipelineConfig::new(
876 PipelineSection::new("demo"),
877 ComponentConfig::new("memory", body()),
878 ComponentConfig::new("memory", body()),
879 ),
880 PipelineConfig::from_str(MINIMAL).unwrap()
881 );
882 }
883
884 #[test]
887 fn new_multi_sink_matches_the_yaml_defaults() {
888 let body = || YamlValue::Mapping(Default::default());
889 let sinks = BTreeMap::from([
890 ("eu".to_owned(), ComponentConfig::new("memory", body())),
891 ("us".to_owned(), ComponentConfig::new("memory", body())),
892 ]);
893 let yaml = "
894pipeline: { name: demo }
895source: { memory: {} }
896sinks:
897 eu: { memory: {} }
898 us: { memory: {} }
899";
900 assert_eq!(
901 PipelineConfig::new_multi_sink(
902 PipelineSection::new("demo"),
903 ComponentConfig::new("memory", body()),
904 sinks,
905 ),
906 PipelineConfig::from_str(yaml).unwrap()
907 );
908 }
909
910 #[test]
914 fn with_deserializer_matches_the_yaml_form() {
915 let body = || YamlValue::Mapping(Default::default());
916 let yaml = "
917pipeline: { name: demo }
918source: { memory: {} }
919deserializer: { json: {} }
920sink: { memory: {} }
921";
922 assert_eq!(
923 PipelineConfig::new(
924 PipelineSection::new("demo"),
925 ComponentConfig::new("memory", body()),
926 ComponentConfig::new("memory", body()),
927 )
928 .with_deserializer(ComponentConfig::new("json", body())),
929 PipelineConfig::from_str(yaml).unwrap()
930 );
931 }
932
933 #[test]
936 fn a_constructed_config_validates() {
937 let body = || YamlValue::Mapping(Default::default());
938 PipelineConfig::new(
939 PipelineSection::new("demo"),
940 ComponentConfig::new("memory", body()),
941 ComponentConfig::new("memory", body()),
942 )
943 .validate()
944 .expect("the single-sink form validates");
945
946 PipelineConfig::new_multi_sink(
947 PipelineSection::new("demo"),
948 ComponentConfig::new("memory", body()),
949 BTreeMap::from([("eu".to_owned(), ComponentConfig::new("memory", body()))]),
950 )
951 .validate()
952 .expect("the multi-sink form validates");
953 }
954
955 #[test]
956 fn admin_listen_takes_an_address_or_none() {
957 let with = |admin: &str| {
958 PipelineConfig::from_str(&format!(
959 "pipeline: {{ name: demo }}\n{admin}\nsource: {{ memory: {{}} }}\n\
960 sink: {{ memory: {{}} }}\n"
961 ))
962 };
963
964 let bound = with("admin: { listen: 127.0.0.1:7777 }").expect("an address parses");
965 assert_eq!(
966 bound.admin.listen,
967 Some(SocketAddr::from(([127, 0, 0, 1], 7777)))
968 );
969
970 let off = with("admin: { listen: none }").expect("`none` parses");
971 assert_eq!(off.admin.listen, None, "`none` asks for no server");
972
973 let err = with("admin: { listen: 9090 }").expect_err("a bare port is not an address");
976 let msg = err.to_string();
977 assert!(msg.contains("admin.listen"), "{msg}");
978 assert!(msg.contains(r#"a socket address or "none""#), "{msg}");
979
980 for null in [
984 "admin: { listen: ~ }",
985 "admin: { listen: null }",
986 "admin:\n listen:",
987 ] {
988 let msg = with(null)
989 .expect_err("a null is not an address")
990 .to_string();
991 assert!(
992 msg.contains(r#"a socket address or "none""#),
993 "{null}: {msg}"
994 );
995 }
996 }
997
998 #[test]
1002 fn the_metrics_section_takes_no_bind_address() {
1003 let err = PipelineConfig::from_str(
1004 "pipeline: { name: demo }\nmetrics: { listen: 0.0.0.0:9090 }\n\
1005 source: { memory: {} }\nsink: { memory: {} }\n",
1006 )
1007 .expect_err("metrics carries no bind address");
1008 let msg = err.to_string();
1009 assert!(msg.contains("listen"), "{msg}");
1010 }
1011
1012 #[test]
1013 fn sink_chunk_block_parses_and_resolves() {
1014 let yaml = r#"
1015pipeline: { name: demo }
1016source: { memory: {} }
1017sink:
1018 memory:
1019 chunk: { target_bytes: 512KiB, encode_policy: fail }
1020"#;
1021 let cfg = PipelineConfig::from_str(yaml).unwrap();
1022 let chunk = cfg
1023 .sink_config("default")
1024 .unwrap()
1025 .resolved_chunk()
1026 .unwrap()
1027 .expect("chunk present");
1028 assert_eq!(chunk.target_bytes, 512 * 1024);
1029 assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
1030 }
1031
1032 #[test]
1033 fn chunk_on_a_source_section_is_rejected() {
1034 let yaml = r#"
1035pipeline: { name: demo }
1036source:
1037 memory:
1038 chunk: { target_bytes: 64KiB }
1039sink: { memory: {} }
1040"#;
1041 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1042 assert!(err.contains("chunk"), "{err}");
1043 assert!(err.contains("source"), "{err}");
1044 }
1045
1046 #[test]
1049 fn a_coordination_key_fails_to_load() {
1050 let base = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n";
1051 for section in [
1052 "coordination: ~\n",
1053 "coordination: {}\n",
1054 "coordination: { store: { nats: { servers: [\"nats://u:hunter2@n:4222\"], job: j } } }\n",
1055 "feed: { memory: {} }\ncoordination: { store: { memory: {} } }\n",
1056 ] {
1057 let err = PipelineConfig::from_str(&format!("{base}{section}"))
1058 .unwrap_err()
1059 .to_string();
1060 assert!(err.contains("unknown field"), "{section}: {err}");
1061 assert!(err.contains("coordination"), "{section}: {err}");
1062 assert!(!err.contains("hunter2"), "{section}: {err}");
1063 }
1064 }
1065
1066 #[test]
1069 fn feed_section_is_parsed() {
1070 let base = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n";
1071 assert!(PipelineConfig::from_str(base).unwrap().feed.is_none());
1072
1073 let cfg = PipelineConfig::from_str(&format!("{base}feed:\n memory: {{}}\n")).unwrap();
1074 assert_eq!(cfg.feed.expect("section").backend().type_tag(), "memory");
1075
1076 let err = PipelineConfig::from_str(&format!("{base}feed: {{ units_held: 2 }}\n"))
1077 .unwrap_err()
1078 .to_string();
1079 assert!(err.contains("`feed`"), "{err}");
1080 }
1081
1082 #[test]
1083 fn zero_target_bytes_in_yaml_is_rejected_at_load() {
1084 let yaml = r#"
1085pipeline: { name: demo }
1086source: { memory: {} }
1087sink:
1088 memory:
1089 chunk: { target_bytes: 0B }
1090"#;
1091 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1094 assert!(err.contains("chunk.target_bytes"), "{err}");
1095 }
1096
1097 #[test]
1098 fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
1099 let yaml = r#"
1103pipeline: { name: demo }
1104source: { memory: {} }
1105sinks:
1106 hot: { memory: {} }
1107 cold:
1108 memory:
1109 chunk: { encode_policy: retry }
1110"#;
1111 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1112 assert!(err.contains("sinks.cold"), "{err}");
1113 let yaml = r#"
1114pipeline: { name: demo }
1115source: { memory: {} }
1116sinks:
1117 cold:
1118 memory:
1119 chunk: { target_bytes: 0B }
1120"#;
1121 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1122 assert!(err.contains("sinks.cold"), "{err}");
1123 assert!(err.contains("chunk.target_bytes"), "{err}");
1124 }
1125
1126 #[test]
1127 fn full_design_doc_example_parses() {
1128 let yaml = r#"
1132pipeline: { name: orders, threads: 4, io_threads: 2 }
1133checkpoint: { interval: 5s, max_pending_batches: 1024 }
1134backpressure: { max_inflight_bytes: 256MiB }
1135source:
1136 kafka:
1137 brokers: ${KAFKA_BROKERS:-localhost:9092}
1138 topic: orders
1139 group_id: orders-etl
1140 rdkafka: { fetch.message.max.bytes: "1048576" }
1141deserializer:
1142 avro:
1143 mode: confluent
1144 registry:
1145 url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
1146sink:
1147 clickhouse:
1148 table: orders_local
1149 shards:
1150 - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
1151 - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
1152 batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
1153 inflight: { max_per_shard: 2 }
1154 retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
1155admin: { listen: 0.0.0.0:9090 }
1156metrics: { exporter: prometheus }
1157"#;
1158 let cfg = PipelineConfig::from_str(yaml).unwrap();
1159 assert_eq!(cfg.pipeline.threads, Some(4));
1160 assert_eq!(cfg.source.type_tag(), "kafka");
1161 assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
1162 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
1163
1164 #[derive(Debug, serde::Deserialize)]
1167 struct KafkaProbe {
1168 brokers: String,
1169 group_id: String,
1170 #[serde(flatten)]
1171 _rest: serde_yaml::Value,
1172 }
1173 let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
1174 assert_eq!(kafka.brokers, "localhost:9092");
1175 assert_eq!(kafka.group_id, "orders-etl");
1176
1177 #[derive(Debug, serde::Deserialize)]
1180 struct AvroProbe {
1181 registry: RegistryProbe,
1182 }
1183 #[derive(Debug, serde::Deserialize)]
1184 struct RegistryProbe {
1185 url: String,
1186 }
1187 let avro: AvroProbe = cfg
1188 .deserializer
1189 .as_ref()
1190 .unwrap()
1191 .deserialize_into()
1192 .unwrap();
1193 assert_eq!(avro.registry.url, "http://sr:8081");
1194
1195 #[derive(Debug, serde::Deserialize)]
1196 struct ChProbe {
1197 shards: Vec<ShardProbe>,
1198 }
1199 #[derive(Debug, serde::Deserialize)]
1200 struct ShardProbe {
1201 replicas: Vec<String>,
1202 }
1203 let ch: ChProbe = cfg
1204 .sink_config("default")
1205 .unwrap()
1206 .deserialize_into()
1207 .unwrap();
1208 assert_eq!(
1209 ch.shards[0].replicas,
1210 ["http://ch-0-0:8123", "http://ch-0-1:8123"]
1211 );
1212 }
1213
1214 #[test]
1215 fn single_sink_resolves_as_default() {
1216 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
1217 assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
1218 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
1219 assert!(cfg.sink_config("other").is_err());
1220 }
1221
1222 #[test]
1223 fn sinks_map_parses_and_resolves_by_name() {
1224 let yaml = r#"
1225pipeline: { name: demo }
1226source: { memory: {} }
1227sinks:
1228 type_a: { memory: {} }
1229 type_b: { memory: {} }
1230"#;
1231 let cfg = PipelineConfig::from_str(yaml).unwrap();
1232 assert_eq!(
1233 cfg.sink_names(),
1234 vec!["type_a".to_string(), "type_b".to_string()]
1235 );
1236 assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
1237 assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
1238 assert!(cfg.sink_config("default").is_err());
1240 }
1241
1242 #[test]
1243 fn sink_and_sinks_are_mutually_exclusive() {
1244 let both = r#"
1245pipeline: { name: demo }
1246source: { memory: {} }
1247sink: { memory: {} }
1248sinks:
1249 a: { memory: {} }
1250"#;
1251 assert!(matches!(
1252 PipelineConfig::from_str(both),
1253 Err(ConfigError::Validation(_))
1254 ));
1255
1256 let neither = r#"
1257pipeline: { name: demo }
1258source: { memory: {} }
1259"#;
1260 assert!(matches!(
1261 PipelineConfig::from_str(neither),
1262 Err(ConfigError::Validation(_))
1263 ));
1264
1265 let empty = r#"
1266pipeline: { name: demo }
1267source: { memory: {} }
1268sinks: {}
1269"#;
1270 assert!(matches!(
1271 PipelineConfig::from_str(empty),
1272 Err(ConfigError::Validation(_))
1273 ));
1274 }
1275
1276 #[test]
1277 fn reserved_sink_names_are_rejected() {
1278 let reserved = r#"
1281pipeline: { name: demo }
1282source: { memory: {} }
1283sinks:
1284 sink: { memory: {} }
1285"#;
1286 assert!(matches!(
1287 PipelineConfig::from_str(reserved),
1288 Err(ConfigError::Validation(msg)) if msg.contains("reserved")
1289 ));
1290
1291 let empty_name = r#"
1292pipeline: { name: demo }
1293source: { memory: {} }
1294sinks:
1295 "": { memory: {} }
1296"#;
1297 assert!(matches!(
1298 PipelineConfig::from_str(empty_name),
1299 Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
1300 ));
1301 }
1302
1303 #[test]
1304 fn unknown_fields_are_rejected_at_every_typed_level() {
1305 for (yaml, field) in [
1306 (
1307 "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
1308 "bogus",
1309 ),
1310 (
1311 "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
1312 "intervall",
1313 ),
1314 (
1315 "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
1316 "port",
1317 ),
1318 (
1319 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
1320 "sinks",
1321 ),
1322 ] {
1323 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1324 assert!(err.contains(field), "expected `{field}` in error: {err}");
1325 }
1326 }
1327
1328 #[test]
1329 fn parse_errors_carry_the_yaml_path() {
1330 let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
1331 let err = PipelineConfig::from_str(yaml).unwrap_err();
1332 let text = err.to_string();
1333 assert!(text.contains("pipeline.io_threads"), "{text}");
1334 }
1335
1336 #[test]
1340 fn parse_errors_omit_the_value() {
1341 for (yaml, path, expected) in [
1342 (
1343 "pipeline: { name: x }\ncheckpoint: { interval: hunter2 }\nsource: { m: {} }\nsink: { m: {} }",
1344 "checkpoint.interval",
1345 "expected a duration at line 2 column",
1346 ),
1347 (
1348 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: { password: !!int hunter2 } }",
1349 "sink.m.password",
1350 "expected an integer at line 3 column",
1351 ),
1352 ] {
1353 let err = PipelineConfig::from_str(yaml).unwrap_err();
1354 for shown in [err.to_string(), format!("{err:?}")] {
1355 assert!(!shown.contains("hunter2"), "{shown}");
1356 assert!(shown.contains(path), "{shown}");
1357 assert!(shown.contains(expected), "{shown}");
1358 }
1359 }
1360 }
1361
1362 #[test]
1363 fn validation_rules() {
1364 let cases = [
1365 (
1366 "pipeline: { name: ' ' }\nsource: { m: {} }\nsink: { m: {} }",
1367 "pipeline.name",
1368 ),
1369 (
1370 "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1371 "io_threads",
1372 ),
1373 (
1374 "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1375 "threads",
1376 ),
1377 (
1378 "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1379 "interval",
1380 ),
1381 (
1382 "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
1383 "at least 100ms",
1384 ),
1385 (
1386 "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1387 "max_pending_batches",
1388 ),
1389 (
1390 "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1391 "drain_timeout",
1392 ),
1393 (
1394 "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1395 "stalled_fail_after",
1396 ),
1397 (
1398 "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1399 "max_inflight_bytes",
1400 ),
1401 (
1402 "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
1403 "low_ratio",
1404 ),
1405 (
1406 "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
1407 "high_ratio",
1408 ),
1409 ];
1410 for (yaml, needle) in cases {
1411 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1412 assert!(err.contains(needle), "expected `{needle}` in: {err}");
1413 }
1414 }
1415
1416 #[test]
1417 fn missing_required_sections_error_clearly() {
1418 let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
1419 .unwrap_err()
1420 .to_string();
1421 assert!(err.contains("source"), "{err}");
1422 let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
1423 .unwrap_err()
1424 .to_string();
1425 assert!(err.contains("pipeline"), "{err}");
1426 }
1427
1428 #[test]
1429 fn interpolation_failures_surface_with_position() {
1430 let err = PipelineConfig::from_str("pipeline:\n name: ${UNSET_VAR_FOR_TEST}\n")
1431 .unwrap_err()
1432 .to_string();
1433 assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
1434 assert!(err.contains("line 2"), "{err}");
1435 }
1436
1437 #[test]
1438 fn from_path_reads_interpolates_and_reports_io_errors() {
1439 use std::io::Write as _;
1440 let mut file = tempfile::NamedTempFile::new().unwrap();
1441 write!(
1442 file,
1443 "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
1444 )
1445 .unwrap();
1446 let cfg = PipelineConfig::from_path(file.path()).unwrap();
1447 assert_eq!(cfg.pipeline.name, "from-file");
1448
1449 let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
1450 assert!(matches!(err, ConfigError::Io { .. }));
1451 assert!(err.to_string().contains("/nonexistent/spate.yaml"));
1452 }
1453
1454 #[test]
1455 fn equal_inputs_parse_to_equal_configs() {
1456 let a = PipelineConfig::from_str(MINIMAL).unwrap();
1457 let b = PipelineConfig::from_str(MINIMAL).unwrap();
1458 assert_eq!(a, b);
1459 let c = PipelineConfig::from_str(
1460 "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
1461 )
1462 .unwrap();
1463 assert_ne!(a, c);
1464 }
1465}