1mod chunk;
63mod component;
64mod error;
65mod interpolate;
66
67pub use component::ComponentConfig;
68pub use error::ConfigError;
69
70pub use serde_yaml::Value as YamlValue;
80
81use bytesize::ByteSize;
82use serde::Deserialize;
83use std::collections::BTreeMap;
84use std::net::SocketAddr;
85use std::path::Path;
86use std::time::Duration;
87
88#[derive(Debug, PartialEq, Deserialize)]
97#[serde(deny_unknown_fields)]
98#[non_exhaustive]
99pub struct PipelineConfig {
100 pub pipeline: PipelineSection,
102 #[serde(default)]
104 pub admin: AdminSection,
105 #[serde(default)]
107 pub backpressure: BackpressureSection,
108 #[serde(default)]
110 pub checkpoint: CheckpointSection,
111 #[serde(default)]
113 pub metrics: MetricsSection,
114 pub source: ComponentConfig,
116 #[serde(default)]
119 pub deserializer: Option<ComponentConfig>,
120 #[serde(default)]
124 pub sink: Option<ComponentConfig>,
125 #[serde(default)]
129 pub sinks: Option<BTreeMap<String, ComponentConfig>>,
130}
131
132#[derive(Debug, PartialEq, Deserialize)]
138#[serde(deny_unknown_fields)]
139#[non_exhaustive]
140pub struct PipelineSection {
141 pub name: String,
143 #[serde(default)]
146 pub threads: Option<usize>,
147 #[serde(default = "defaults::io_threads")]
150 pub io_threads: usize,
151 #[serde(default)]
153 pub pinning: PinningMode,
154}
155
156impl PipelineSection {
157 #[must_use]
169 pub fn new(name: impl Into<String>) -> PipelineSection {
170 PipelineSection {
171 name: name.into(),
172 threads: None,
173 io_threads: defaults::io_threads(),
174 pinning: PinningMode::default(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
181#[serde(rename_all = "snake_case")]
182#[non_exhaustive]
183pub enum PinningMode {
184 #[default]
187 Off,
188 Compact,
190}
191
192#[derive(Debug, PartialEq, Deserialize)]
198#[serde(deny_unknown_fields, default)]
199#[non_exhaustive]
200pub struct CheckpointSection {
201 #[serde(with = "humantime_serde")]
203 pub interval: Duration,
204 pub max_pending_batches: usize,
210 #[serde(with = "humantime_serde")]
213 pub drain_timeout: Duration,
214 #[serde(with = "humantime_serde")]
222 pub stalled_fail_after: Duration,
223}
224
225impl Default for CheckpointSection {
226 fn default() -> Self {
227 CheckpointSection {
228 interval: Duration::from_secs(5),
229 max_pending_batches: 1024,
230 drain_timeout: Duration::from_secs(25),
231 stalled_fail_after: Duration::from_secs(120),
232 }
233 }
234}
235
236#[derive(Debug, PartialEq, Deserialize)]
242#[serde(deny_unknown_fields, default)]
243#[non_exhaustive]
244pub struct BackpressureSection {
245 pub max_inflight_bytes: ByteSize,
248 pub high_ratio: f64,
250 pub low_ratio: f64,
252 #[serde(with = "humantime_serde")]
255 pub min_pause: Duration,
256}
257
258impl Default for BackpressureSection {
259 fn default() -> Self {
260 BackpressureSection {
261 max_inflight_bytes: ByteSize::mib(256),
262 high_ratio: 0.8,
263 low_ratio: 0.5,
264 min_pause: Duration::from_millis(500),
265 }
266 }
267}
268
269#[derive(Debug, PartialEq, Deserialize)]
280#[serde(deny_unknown_fields, default)]
281#[non_exhaustive]
282pub struct AdminSection {
283 #[serde(deserialize_with = "listen_or_none")]
290 pub listen: Option<SocketAddr>,
291}
292
293impl Default for AdminSection {
294 fn default() -> Self {
295 AdminSection {
296 listen: Some(SocketAddr::from(([0, 0, 0, 0], 9090))),
297 }
298 }
299}
300
301fn listen_or_none<'de, D>(de: D) -> Result<Option<SocketAddr>, D::Error>
308where
309 D: serde::Deserializer<'de>,
310{
311 struct Visitor;
312
313 const EXPECTING: &str = r#"a socket address or "none""#;
314
315 impl serde::de::Visitor<'_> for Visitor {
316 type Value = Option<SocketAddr>;
317
318 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 f.write_str(EXPECTING)
320 }
321
322 fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
323 if text == "none" {
324 return Ok(None);
325 }
326 text.parse()
327 .map(Some)
328 .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(text), &EXPECTING))
329 }
330 }
331
332 de.deserialize_any(Visitor)
333}
334
335#[derive(Debug, PartialEq, Deserialize)]
340#[serde(deny_unknown_fields, default)]
341#[non_exhaustive]
342pub struct MetricsSection {
343 pub exporter: MetricsExporter,
345 pub per_partition_detail: bool,
348 pub e2e_basis: E2eBasis,
350}
351
352impl Default for MetricsSection {
353 fn default() -> Self {
354 MetricsSection {
355 exporter: MetricsExporter::Prometheus,
356 per_partition_detail: false,
357 e2e_basis: E2eBasis::Ingest,
358 }
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
364#[serde(rename_all = "snake_case")]
365#[non_exhaustive]
366pub enum MetricsExporter {
367 #[default]
369 Prometheus,
370 None,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
376#[serde(rename_all = "snake_case")]
377#[non_exhaustive]
378pub enum E2eBasis {
379 #[default]
381 Ingest,
382 Event,
385}
386
387mod defaults {
388 pub(super) fn io_threads() -> usize {
389 2
390 }
391}
392
393fn labelled(mut component: ComponentConfig, section: &'static str) -> ComponentConfig {
396 component.set_section(section);
397 component
398}
399
400impl PipelineConfig {
401 #[must_use]
424 pub fn new(
425 pipeline: PipelineSection,
426 source: ComponentConfig,
427 sink: ComponentConfig,
428 ) -> PipelineConfig {
429 Self::assemble(pipeline, source, Some(labelled(sink, "sink")), None)
430 }
431
432 #[must_use]
460 pub fn new_multi_sink(
461 pipeline: PipelineSection,
462 source: ComponentConfig,
463 sinks: BTreeMap<String, ComponentConfig>,
464 ) -> PipelineConfig {
465 let sinks = sinks
466 .into_iter()
467 .map(|(name, sink)| (name, labelled(sink, "sink")))
468 .collect();
469 Self::assemble(pipeline, source, None, Some(sinks))
470 }
471
472 #[must_use]
492 pub fn with_deserializer(mut self, deserializer: ComponentConfig) -> PipelineConfig {
493 self.deserializer = Some(labelled(deserializer, "deserializer"));
494 self
495 }
496
497 fn assemble(
500 pipeline: PipelineSection,
501 source: ComponentConfig,
502 sink: Option<ComponentConfig>,
503 sinks: Option<BTreeMap<String, ComponentConfig>>,
504 ) -> PipelineConfig {
505 PipelineConfig {
506 pipeline,
507 admin: AdminSection::default(),
508 backpressure: BackpressureSection::default(),
509 checkpoint: CheckpointSection::default(),
510 metrics: MetricsSection::default(),
511 source: labelled(source, "source"),
512 deserializer: None,
513 sink,
514 sinks,
515 }
516 }
517
518 #[expect(
524 clippy::should_implement_trait,
525 reason = "paired with from_path; no trait import required at call sites"
526 )]
527 pub fn from_str(text: &str) -> Result<Self, ConfigError> {
528 let interpolated = interpolate::interpolate(text)?;
529 Self::parse_interpolated(&interpolated)
530 }
531
532 pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
534 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
535 path: path.to_owned(),
536 source,
537 })?;
538 Self::from_str(&text)
539 }
540
541 fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
542 let de = serde_yaml::Deserializer::from_str(text);
543 let mut cfg: PipelineConfig =
544 serde_path_to_error::deserialize(de).map_err(|e| ConfigError::Parse {
545 path: e.path().to_string(),
546 source: e.into_inner(),
547 })?;
548 cfg.source.set_section("source");
549 if let Some(sink) = cfg.sink.as_mut() {
550 sink.set_section("sink");
551 }
552 if let Some(sinks) = cfg.sinks.as_mut() {
553 for sink in sinks.values_mut() {
554 sink.set_section("sink");
555 }
556 }
557 if let Some(deser) = cfg.deserializer.as_mut() {
558 deser.set_section("deserializer");
559 }
560 cfg.validate()?;
561 Ok(cfg)
562 }
563
564 pub fn validate(&self) -> Result<(), ConfigError> {
568 let fail = |msg: String| Err(ConfigError::Validation(msg));
569
570 if self.pipeline.name.trim().is_empty() {
571 return fail("pipeline.name must not be empty".into());
572 }
573 if self.pipeline.io_threads == 0 {
574 return fail("pipeline.io_threads must be at least 1".into());
575 }
576 if self.pipeline.threads == Some(0) {
577 return fail("pipeline.threads must be at least 1 when set".into());
578 }
579 const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
585 if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
586 return fail(format!(
587 "checkpoint.interval must be at least 100ms (got {:?}): the commit \
588 loop fires every interval, so sub-100ms intervals hammer the \
589 source's offset store without improving durability",
590 self.checkpoint.interval
591 ));
592 }
593 if self.checkpoint.max_pending_batches == 0 {
594 return fail("checkpoint.max_pending_batches must be at least 1".into());
595 }
596 if self.checkpoint.drain_timeout.is_zero() {
597 return fail("checkpoint.drain_timeout must be greater than zero".into());
598 }
599 if self.checkpoint.stalled_fail_after.is_zero() {
600 return fail("checkpoint.stalled_fail_after must be greater than zero".into());
601 }
602 if self.backpressure.max_inflight_bytes.as_u64() == 0 {
603 return fail("backpressure.max_inflight_bytes must be greater than zero".into());
604 }
605 let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
606 if !(low > 0.0 && low < high && high <= 1.0) {
607 return fail(format!(
608 "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
609 (got low_ratio={low}, high_ratio={high})"
610 ));
611 }
612 match (&self.sink, &self.sinks) {
613 (Some(_), Some(_)) => {
614 return fail("set exactly one of `sink:` or `sinks:`, not both".into());
615 }
616 (None, None) => {
617 return fail("a `sink:` or `sinks:` section is required".into());
618 }
619 (None, Some(map)) if map.is_empty() => {
620 return fail("`sinks:` must declare at least one sink".into());
621 }
622 _ => {}
623 }
624 if let Some(sinks) = &self.sinks {
625 for name in sinks.keys() {
626 if name.is_empty() {
627 return fail("`sinks:` names must be non-empty".into());
628 }
629 if name == "sink" {
633 return fail(
634 "the sink name \"sink\" is reserved (it is the default \
635 sink's metric label); rename the `sinks:` entry"
636 .into(),
637 );
638 }
639 }
640 }
641 self.reject_stray_chunk()?;
642 if let Some(sink) = &self.sink {
646 sink.resolved_chunk()?;
647 }
648 if let Some(sinks) = &self.sinks {
649 for (name, sink) in sinks {
650 sink.resolved_chunk()
651 .map_err(|e| name_sinks_entry_error(name, e))?;
652 }
653 }
654 Ok(())
655 }
656
657 pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
665 if self.source.resolved_chunk()?.is_some() {
666 return Err(ConfigError::Validation(
667 "`chunk:` is only valid on a sink section, not `source`".into(),
668 ));
669 }
670 if let Some(deser) = &self.deserializer
671 && deser.resolved_chunk()?.is_some()
672 {
673 return Err(ConfigError::Validation(
674 "`chunk:` is only valid on a sink section, not `deserializer`".into(),
675 ));
676 }
677 Ok(())
678 }
679
680 pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
688 if let Some(sinks) = &self.sinks {
689 sinks.get(name).ok_or_else(|| {
690 let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
691 ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
692 })
693 } else if name == "default" {
694 self.sink
695 .as_ref()
696 .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
697 } else {
698 Err(ConfigError::Validation(format!(
699 "no sink named {name:?}: this pipeline configures a single `sink:` \
700 (address it as \"default\")"
701 )))
702 }
703 }
704
705 #[must_use]
708 pub fn sink_names(&self) -> Vec<String> {
709 match &self.sinks {
710 Some(sinks) => sinks.keys().cloned().collect(),
711 None => vec!["default".to_string()],
712 }
713 }
714}
715
716fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
720 match e {
721 ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
722 ConfigError::Component { context, message } => ConfigError::Component {
723 context: match context.strip_prefix("sink.") {
724 Some(rest) => format!("sinks.{name}.{rest}"),
725 None => format!("sinks.{name}.{context}"),
726 },
727 message,
728 },
729 other => other,
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 const MINIMAL: &str = r#"
738pipeline: { name: demo }
739source: { memory: {} }
740sink: { memory: {} }
741"#;
742
743 #[test]
744 fn minimal_config_applies_documented_defaults() {
745 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
746 assert_eq!(cfg.pipeline.name, "demo");
747 assert_eq!(cfg.pipeline.threads, None);
748 assert_eq!(cfg.pipeline.io_threads, 2);
749 assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
750 assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
751 assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
752 assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
753 assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
754 assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
755 assert_eq!(cfg.backpressure.high_ratio, 0.8);
756 assert_eq!(cfg.backpressure.low_ratio, 0.5);
757 assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
758 assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
759 assert!(!cfg.metrics.per_partition_detail);
760 assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
761 assert_eq!(
762 cfg.admin.listen,
763 Some(SocketAddr::from(([0, 0, 0, 0], 9090)))
764 );
765 assert!(cfg.deserializer.is_none());
766 }
767
768 #[test]
771 fn new_matches_the_yaml_defaults() {
772 let body = || YamlValue::Mapping(Default::default());
773 assert_eq!(
774 PipelineConfig::new(
775 PipelineSection::new("demo"),
776 ComponentConfig::new("memory", body()),
777 ComponentConfig::new("memory", body()),
778 ),
779 PipelineConfig::from_str(MINIMAL).unwrap()
780 );
781 }
782
783 #[test]
786 fn new_multi_sink_matches_the_yaml_defaults() {
787 let body = || YamlValue::Mapping(Default::default());
788 let sinks = BTreeMap::from([
789 ("eu".to_owned(), ComponentConfig::new("memory", body())),
790 ("us".to_owned(), ComponentConfig::new("memory", body())),
791 ]);
792 let yaml = "
793pipeline: { name: demo }
794source: { memory: {} }
795sinks:
796 eu: { memory: {} }
797 us: { memory: {} }
798";
799 assert_eq!(
800 PipelineConfig::new_multi_sink(
801 PipelineSection::new("demo"),
802 ComponentConfig::new("memory", body()),
803 sinks,
804 ),
805 PipelineConfig::from_str(yaml).unwrap()
806 );
807 }
808
809 #[test]
813 fn with_deserializer_matches_the_yaml_form() {
814 let body = || YamlValue::Mapping(Default::default());
815 let yaml = "
816pipeline: { name: demo }
817source: { memory: {} }
818deserializer: { json: {} }
819sink: { memory: {} }
820";
821 assert_eq!(
822 PipelineConfig::new(
823 PipelineSection::new("demo"),
824 ComponentConfig::new("memory", body()),
825 ComponentConfig::new("memory", body()),
826 )
827 .with_deserializer(ComponentConfig::new("json", body())),
828 PipelineConfig::from_str(yaml).unwrap()
829 );
830 }
831
832 #[test]
835 fn a_constructed_config_validates() {
836 let body = || YamlValue::Mapping(Default::default());
837 PipelineConfig::new(
838 PipelineSection::new("demo"),
839 ComponentConfig::new("memory", body()),
840 ComponentConfig::new("memory", body()),
841 )
842 .validate()
843 .expect("the single-sink form validates");
844
845 PipelineConfig::new_multi_sink(
846 PipelineSection::new("demo"),
847 ComponentConfig::new("memory", body()),
848 BTreeMap::from([("eu".to_owned(), ComponentConfig::new("memory", body()))]),
849 )
850 .validate()
851 .expect("the multi-sink form validates");
852 }
853
854 #[test]
855 fn admin_listen_takes_an_address_or_none() {
856 let with = |admin: &str| {
857 PipelineConfig::from_str(&format!(
858 "pipeline: {{ name: demo }}\n{admin}\nsource: {{ memory: {{}} }}\n\
859 sink: {{ memory: {{}} }}\n"
860 ))
861 };
862
863 let bound = with("admin: { listen: 127.0.0.1:7777 }").expect("an address parses");
864 assert_eq!(
865 bound.admin.listen,
866 Some(SocketAddr::from(([127, 0, 0, 1], 7777)))
867 );
868
869 let off = with("admin: { listen: none }").expect("`none` parses");
870 assert_eq!(off.admin.listen, None, "`none` asks for no server");
871
872 let err = with("admin: { listen: 9090 }").expect_err("a bare port is not an address");
875 let msg = err.to_string();
876 assert!(msg.contains("admin.listen"), "{msg}");
877 assert!(msg.contains(r#"a socket address or "none""#), "{msg}");
878
879 for null in [
883 "admin: { listen: ~ }",
884 "admin: { listen: null }",
885 "admin:\n listen:",
886 ] {
887 let msg = with(null)
888 .expect_err("a null is not an address")
889 .to_string();
890 assert!(
891 msg.contains(r#"a socket address or "none""#),
892 "{null}: {msg}"
893 );
894 }
895 }
896
897 #[test]
901 fn the_metrics_section_takes_no_bind_address() {
902 let err = PipelineConfig::from_str(
903 "pipeline: { name: demo }\nmetrics: { listen: 0.0.0.0:9090 }\n\
904 source: { memory: {} }\nsink: { memory: {} }\n",
905 )
906 .expect_err("metrics carries no bind address");
907 let msg = err.to_string();
908 assert!(msg.contains("listen"), "{msg}");
909 }
910
911 #[test]
912 fn sink_chunk_block_parses_and_resolves() {
913 let yaml = r#"
914pipeline: { name: demo }
915source: { memory: {} }
916sink:
917 memory:
918 chunk: { target_bytes: 512KiB, encode_policy: fail }
919"#;
920 let cfg = PipelineConfig::from_str(yaml).unwrap();
921 let chunk = cfg
922 .sink_config("default")
923 .unwrap()
924 .resolved_chunk()
925 .unwrap()
926 .expect("chunk present");
927 assert_eq!(chunk.target_bytes, 512 * 1024);
928 assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
929 }
930
931 #[test]
932 fn chunk_on_a_source_section_is_rejected() {
933 let yaml = r#"
934pipeline: { name: demo }
935source:
936 memory:
937 chunk: { target_bytes: 64KiB }
938sink: { memory: {} }
939"#;
940 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
941 assert!(err.contains("chunk"), "{err}");
942 assert!(err.contains("source"), "{err}");
943 }
944
945 #[test]
946 fn zero_target_bytes_in_yaml_is_rejected_at_load() {
947 let yaml = r#"
948pipeline: { name: demo }
949source: { memory: {} }
950sink:
951 memory:
952 chunk: { target_bytes: 0B }
953"#;
954 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
957 assert!(err.contains("chunk.target_bytes"), "{err}");
958 }
959
960 #[test]
961 fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
962 let yaml = r#"
966pipeline: { name: demo }
967source: { memory: {} }
968sinks:
969 hot: { memory: {} }
970 cold:
971 memory:
972 chunk: { encode_policy: retry }
973"#;
974 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
975 assert!(err.contains("sinks.cold"), "{err}");
976 let yaml = r#"
977pipeline: { name: demo }
978source: { memory: {} }
979sinks:
980 cold:
981 memory:
982 chunk: { target_bytes: 0B }
983"#;
984 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
985 assert!(err.contains("sinks.cold"), "{err}");
986 assert!(err.contains("chunk.target_bytes"), "{err}");
987 }
988
989 #[test]
990 fn full_design_doc_example_parses() {
991 let yaml = r#"
995pipeline: { name: orders, threads: 4, io_threads: 2 }
996checkpoint: { interval: 5s, max_pending_batches: 1024 }
997backpressure: { max_inflight_bytes: 256MiB }
998source:
999 kafka:
1000 brokers: ${KAFKA_BROKERS:-localhost:9092}
1001 topic: orders
1002 group_id: orders-etl
1003 rdkafka: { fetch.message.max.bytes: "1048576" }
1004deserializer:
1005 avro:
1006 mode: confluent
1007 registry:
1008 url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
1009sink:
1010 clickhouse:
1011 table: orders_local
1012 columns: [id, amount, ts]
1013 shards:
1014 - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
1015 - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
1016 batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
1017 inflight: { max_per_shard: 2 }
1018 retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
1019admin: { listen: 0.0.0.0:9090 }
1020metrics: { exporter: prometheus }
1021"#;
1022 let cfg = PipelineConfig::from_str(yaml).unwrap();
1023 assert_eq!(cfg.pipeline.threads, Some(4));
1024 assert_eq!(cfg.source.type_tag(), "kafka");
1025 assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
1026 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
1027
1028 #[derive(Debug, serde::Deserialize)]
1031 struct KafkaProbe {
1032 brokers: String,
1033 group_id: String,
1034 #[serde(flatten)]
1035 _rest: serde_yaml::Value,
1036 }
1037 let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
1038 assert_eq!(kafka.brokers, "localhost:9092");
1039 assert_eq!(kafka.group_id, "orders-etl");
1040
1041 #[derive(Debug, serde::Deserialize)]
1044 struct AvroProbe {
1045 registry: RegistryProbe,
1046 }
1047 #[derive(Debug, serde::Deserialize)]
1048 struct RegistryProbe {
1049 url: String,
1050 }
1051 let avro: AvroProbe = cfg
1052 .deserializer
1053 .as_ref()
1054 .unwrap()
1055 .deserialize_into()
1056 .unwrap();
1057 assert_eq!(avro.registry.url, "http://sr:8081");
1058
1059 #[derive(Debug, serde::Deserialize)]
1060 struct ChProbe {
1061 columns: Vec<String>,
1062 }
1063 let ch: ChProbe = cfg
1064 .sink_config("default")
1065 .unwrap()
1066 .deserialize_into()
1067 .unwrap();
1068 assert_eq!(ch.columns, ["id", "amount", "ts"]);
1069 }
1070
1071 #[test]
1072 fn single_sink_resolves_as_default() {
1073 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
1074 assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
1075 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
1076 assert!(cfg.sink_config("other").is_err());
1077 }
1078
1079 #[test]
1080 fn sinks_map_parses_and_resolves_by_name() {
1081 let yaml = r#"
1082pipeline: { name: demo }
1083source: { memory: {} }
1084sinks:
1085 type_a: { memory: {} }
1086 type_b: { memory: {} }
1087"#;
1088 let cfg = PipelineConfig::from_str(yaml).unwrap();
1089 assert_eq!(
1090 cfg.sink_names(),
1091 vec!["type_a".to_string(), "type_b".to_string()]
1092 );
1093 assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
1094 assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
1095 assert!(cfg.sink_config("default").is_err());
1097 }
1098
1099 #[test]
1100 fn sink_and_sinks_are_mutually_exclusive() {
1101 let both = r#"
1102pipeline: { name: demo }
1103source: { memory: {} }
1104sink: { memory: {} }
1105sinks:
1106 a: { memory: {} }
1107"#;
1108 assert!(matches!(
1109 PipelineConfig::from_str(both),
1110 Err(ConfigError::Validation(_))
1111 ));
1112
1113 let neither = r#"
1114pipeline: { name: demo }
1115source: { memory: {} }
1116"#;
1117 assert!(matches!(
1118 PipelineConfig::from_str(neither),
1119 Err(ConfigError::Validation(_))
1120 ));
1121
1122 let empty = r#"
1123pipeline: { name: demo }
1124source: { memory: {} }
1125sinks: {}
1126"#;
1127 assert!(matches!(
1128 PipelineConfig::from_str(empty),
1129 Err(ConfigError::Validation(_))
1130 ));
1131 }
1132
1133 #[test]
1134 fn reserved_sink_names_are_rejected() {
1135 let reserved = r#"
1138pipeline: { name: demo }
1139source: { memory: {} }
1140sinks:
1141 sink: { memory: {} }
1142"#;
1143 assert!(matches!(
1144 PipelineConfig::from_str(reserved),
1145 Err(ConfigError::Validation(msg)) if msg.contains("reserved")
1146 ));
1147
1148 let empty_name = r#"
1149pipeline: { name: demo }
1150source: { memory: {} }
1151sinks:
1152 "": { memory: {} }
1153"#;
1154 assert!(matches!(
1155 PipelineConfig::from_str(empty_name),
1156 Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
1157 ));
1158 }
1159
1160 #[test]
1161 fn unknown_fields_are_rejected_at_every_typed_level() {
1162 for (yaml, field) in [
1163 (
1164 "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
1165 "bogus",
1166 ),
1167 (
1168 "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
1169 "intervall",
1170 ),
1171 (
1172 "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
1173 "port",
1174 ),
1175 (
1176 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
1177 "sinks",
1178 ),
1179 ] {
1180 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1181 assert!(err.contains(field), "expected `{field}` in error: {err}");
1182 }
1183 }
1184
1185 #[test]
1186 fn parse_errors_carry_the_yaml_path() {
1187 let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
1188 let err = PipelineConfig::from_str(yaml).unwrap_err();
1189 let text = err.to_string();
1190 assert!(text.contains("pipeline.io_threads"), "{text}");
1191 }
1192
1193 #[test]
1194 fn validation_rules() {
1195 let cases = [
1196 (
1197 "pipeline: { name: ' ' }\nsource: { m: {} }\nsink: { m: {} }",
1198 "pipeline.name",
1199 ),
1200 (
1201 "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1202 "io_threads",
1203 ),
1204 (
1205 "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1206 "threads",
1207 ),
1208 (
1209 "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1210 "interval",
1211 ),
1212 (
1213 "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
1214 "at least 100ms",
1215 ),
1216 (
1217 "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1218 "max_pending_batches",
1219 ),
1220 (
1221 "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1222 "drain_timeout",
1223 ),
1224 (
1225 "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1226 "stalled_fail_after",
1227 ),
1228 (
1229 "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1230 "max_inflight_bytes",
1231 ),
1232 (
1233 "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
1234 "low_ratio",
1235 ),
1236 (
1237 "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
1238 "high_ratio",
1239 ),
1240 ];
1241 for (yaml, needle) in cases {
1242 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1243 assert!(err.contains(needle), "expected `{needle}` in: {err}");
1244 }
1245 }
1246
1247 #[test]
1248 fn missing_required_sections_error_clearly() {
1249 let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
1250 .unwrap_err()
1251 .to_string();
1252 assert!(err.contains("source"), "{err}");
1253 let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
1254 .unwrap_err()
1255 .to_string();
1256 assert!(err.contains("pipeline"), "{err}");
1257 }
1258
1259 #[test]
1260 fn interpolation_failures_surface_with_position() {
1261 let err = PipelineConfig::from_str("pipeline:\n name: ${UNSET_VAR_FOR_TEST}\n")
1262 .unwrap_err()
1263 .to_string();
1264 assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
1265 assert!(err.contains("line 2"), "{err}");
1266 }
1267
1268 #[test]
1269 fn from_path_reads_interpolates_and_reports_io_errors() {
1270 use std::io::Write as _;
1271 let mut file = tempfile::NamedTempFile::new().unwrap();
1272 write!(
1273 file,
1274 "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
1275 )
1276 .unwrap();
1277 let cfg = PipelineConfig::from_path(file.path()).unwrap();
1278 assert_eq!(cfg.pipeline.name, "from-file");
1279
1280 let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
1281 assert!(matches!(err, ConfigError::Io { .. }));
1282 assert!(err.to_string().contains("/nonexistent/spate.yaml"));
1283 }
1284
1285 #[test]
1286 fn equal_inputs_parse_to_equal_configs() {
1287 let a = PipelineConfig::from_str(MINIMAL).unwrap();
1288 let b = PipelineConfig::from_str(MINIMAL).unwrap();
1289 assert_eq!(a, b);
1290 let c = PipelineConfig::from_str(
1291 "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
1292 )
1293 .unwrap();
1294 assert_ne!(a, c);
1295 }
1296}