1mod component;
57mod error;
58mod interpolate;
59
60pub use component::ComponentConfig;
61pub use error::ConfigError;
62
63pub use serde_yaml::Value as YamlValue;
73
74use bytesize::ByteSize;
75use serde::Deserialize;
76use std::net::SocketAddr;
77use std::path::Path;
78use std::time::Duration;
79
80#[derive(Debug, PartialEq, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct PipelineConfig {
86 pub pipeline: PipelineSection,
88 #[serde(default)]
90 pub checkpoint: CheckpointSection,
91 #[serde(default)]
93 pub backpressure: BackpressureSection,
94 #[serde(default)]
96 pub metrics: MetricsSection,
97 pub source: ComponentConfig,
99 #[serde(default)]
102 pub deserializer: Option<ComponentConfig>,
103 pub sink: ComponentConfig,
105}
106
107#[derive(Debug, PartialEq, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct PipelineSection {
111 pub name: String,
113 #[serde(default)]
116 pub threads: Option<usize>,
117 #[serde(default = "defaults::io_threads")]
120 pub io_threads: usize,
121 #[serde(default)]
123 pub pinning: PinningMode,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
128#[serde(rename_all = "snake_case")]
129#[non_exhaustive]
130pub enum PinningMode {
131 #[default]
134 Off,
135 Compact,
137}
138
139#[derive(Debug, PartialEq, Deserialize)]
141#[serde(deny_unknown_fields, default)]
142pub struct CheckpointSection {
143 #[serde(with = "humantime_serde")]
145 pub interval: Duration,
146 pub max_pending_batches: usize,
149 #[serde(with = "humantime_serde")]
152 pub drain_timeout: Duration,
153 #[serde(with = "humantime_serde")]
161 pub stalled_fail_after: Duration,
162}
163
164impl Default for CheckpointSection {
165 fn default() -> Self {
166 CheckpointSection {
167 interval: Duration::from_secs(5),
168 max_pending_batches: 1024,
169 drain_timeout: Duration::from_secs(25),
170 stalled_fail_after: Duration::from_secs(120),
171 }
172 }
173}
174
175#[derive(Debug, PartialEq, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct BackpressureSection {
179 pub max_inflight_bytes: ByteSize,
182 pub high_ratio: f64,
184 pub low_ratio: f64,
186 #[serde(with = "humantime_serde")]
189 pub min_pause: Duration,
190}
191
192impl Default for BackpressureSection {
193 fn default() -> Self {
194 BackpressureSection {
195 max_inflight_bytes: ByteSize::mib(256),
196 high_ratio: 0.8,
197 low_ratio: 0.5,
198 min_pause: Duration::from_millis(500),
199 }
200 }
201}
202
203#[derive(Debug, PartialEq, Deserialize)]
205#[serde(deny_unknown_fields, default)]
206pub struct MetricsSection {
207 pub exporter: MetricsExporter,
209 pub listen: SocketAddr,
211 pub per_partition_detail: bool,
214 pub e2e_basis: E2eBasis,
216}
217
218impl Default for MetricsSection {
219 fn default() -> Self {
220 MetricsSection {
221 exporter: MetricsExporter::Prometheus,
222 listen: SocketAddr::from(([0, 0, 0, 0], 9090)),
223 per_partition_detail: false,
224 e2e_basis: E2eBasis::Ingest,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
231#[serde(rename_all = "snake_case")]
232#[non_exhaustive]
233pub enum MetricsExporter {
234 #[default]
236 Prometheus,
237 None,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
243#[serde(rename_all = "snake_case")]
244#[non_exhaustive]
245pub enum E2eBasis {
246 #[default]
248 Ingest,
249 Event,
252}
253
254mod defaults {
255 pub(super) fn io_threads() -> usize {
256 2
257 }
258}
259
260impl PipelineConfig {
261 #[expect(
267 clippy::should_implement_trait,
268 reason = "paired with from_path; no trait import required at call sites"
269 )]
270 pub fn from_str(text: &str) -> Result<Self, ConfigError> {
271 let interpolated = interpolate::interpolate(text)?;
272 Self::parse_interpolated(&interpolated)
273 }
274
275 pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
277 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
278 path: path.to_owned(),
279 source,
280 })?;
281 Self::from_str(&text)
282 }
283
284 fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
285 let de = serde_yaml::Deserializer::from_str(text);
286 let mut cfg: PipelineConfig =
287 serde_path_to_error::deserialize(de).map_err(|e| ConfigError::Parse {
288 path: e.path().to_string(),
289 source: e.into_inner(),
290 })?;
291 cfg.source.set_section("source");
292 cfg.sink.set_section("sink");
293 if let Some(deser) = cfg.deserializer.as_mut() {
294 deser.set_section("deserializer");
295 }
296 cfg.validate()?;
297 Ok(cfg)
298 }
299
300 pub fn validate(&self) -> Result<(), ConfigError> {
304 let fail = |msg: String| Err(ConfigError::Validation(msg));
305
306 if self.pipeline.name.trim().is_empty() {
307 return fail("pipeline.name must not be empty".into());
308 }
309 if self.pipeline.io_threads == 0 {
310 return fail("pipeline.io_threads must be at least 1".into());
311 }
312 if self.pipeline.threads == Some(0) {
313 return fail("pipeline.threads must be at least 1 when set".into());
314 }
315 const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
321 if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
322 return fail(format!(
323 "checkpoint.interval must be at least 100ms (got {:?}): the commit \
324 loop fires every interval, so sub-100ms intervals hammer the \
325 source's offset store without improving durability",
326 self.checkpoint.interval
327 ));
328 }
329 if self.checkpoint.max_pending_batches == 0 {
330 return fail("checkpoint.max_pending_batches must be at least 1".into());
331 }
332 if self.checkpoint.drain_timeout.is_zero() {
333 return fail("checkpoint.drain_timeout must be greater than zero".into());
334 }
335 if self.checkpoint.stalled_fail_after.is_zero() {
336 return fail("checkpoint.stalled_fail_after must be greater than zero".into());
337 }
338 if self.backpressure.max_inflight_bytes.as_u64() == 0 {
339 return fail("backpressure.max_inflight_bytes must be greater than zero".into());
340 }
341 let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
342 if !(low > 0.0 && low < high && high <= 1.0) {
343 return fail(format!(
344 "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
345 (got low_ratio={low}, high_ratio={high})"
346 ));
347 }
348 Ok(())
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 const MINIMAL: &str = r#"
357pipeline: { name: demo }
358source: { memory: {} }
359sink: { memory: {} }
360"#;
361
362 #[test]
363 fn minimal_config_applies_documented_defaults() {
364 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
365 assert_eq!(cfg.pipeline.name, "demo");
366 assert_eq!(cfg.pipeline.threads, None);
367 assert_eq!(cfg.pipeline.io_threads, 2);
368 assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
369 assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
370 assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
371 assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
372 assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
373 assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
374 assert_eq!(cfg.backpressure.high_ratio, 0.8);
375 assert_eq!(cfg.backpressure.low_ratio, 0.5);
376 assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
377 assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
378 assert_eq!(cfg.metrics.listen, SocketAddr::from(([0, 0, 0, 0], 9090)));
379 assert!(!cfg.metrics.per_partition_detail);
380 assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
381 assert!(cfg.deserializer.is_none());
382 }
383
384 #[test]
385 fn full_design_doc_example_parses() {
386 let yaml = r#"
395pipeline: { name: orders, threads: 4, io_threads: 2 }
396checkpoint: { interval: 5s, max_pending_batches: 1024 }
397backpressure: { max_inflight_bytes: 256MiB }
398source:
399 kafka:
400 brokers: ${KAFKA_BROKERS:-localhost:9092}
401 topic: orders
402 group_id: orders-etl
403 rdkafka: { fetch.message.max.bytes: "1048576" }
404deserializer:
405 avro:
406 mode: confluent
407 registry:
408 url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
409sink:
410 clickhouse:
411 table: orders_local
412 columns: [id, amount, ts]
413 shards:
414 - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
415 - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
416 batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
417 inflight: { max_per_shard: 2 }
418 retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
419metrics: { exporter: prometheus, listen: 0.0.0.0:9090 }
420"#;
421 let cfg = PipelineConfig::from_str(yaml).unwrap();
422 assert_eq!(cfg.pipeline.threads, Some(4));
423 assert_eq!(cfg.source.type_tag(), "kafka");
424 assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
425 assert_eq!(cfg.sink.type_tag(), "clickhouse");
426
427 #[derive(Debug, serde::Deserialize)]
430 struct KafkaProbe {
431 brokers: String,
432 group_id: String,
433 #[serde(flatten)]
434 _rest: serde_yaml::Value,
435 }
436 let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
437 assert_eq!(kafka.brokers, "localhost:9092");
438 assert_eq!(kafka.group_id, "orders-etl");
439
440 #[derive(Debug, serde::Deserialize)]
443 struct AvroProbe {
444 registry: RegistryProbe,
445 }
446 #[derive(Debug, serde::Deserialize)]
447 struct RegistryProbe {
448 url: String,
449 }
450 let avro: AvroProbe = cfg
451 .deserializer
452 .as_ref()
453 .unwrap()
454 .deserialize_into()
455 .unwrap();
456 assert_eq!(avro.registry.url, "http://sr:8081");
457
458 #[derive(Debug, serde::Deserialize)]
459 struct ChProbe {
460 columns: Vec<String>,
461 }
462 let ch: ChProbe = cfg.sink.deserialize_into().unwrap();
463 assert_eq!(ch.columns, ["id", "amount", "ts"]);
464 }
465
466 #[test]
467 fn unknown_fields_are_rejected_at_every_typed_level() {
468 for (yaml, field) in [
469 (
470 "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
471 "bogus",
472 ),
473 (
474 "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
475 "intervall",
476 ),
477 (
478 "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
479 "port",
480 ),
481 (
482 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
483 "sinks",
484 ),
485 ] {
486 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
487 assert!(err.contains(field), "expected `{field}` in error: {err}");
488 }
489 }
490
491 #[test]
492 fn parse_errors_carry_the_yaml_path() {
493 let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
494 let err = PipelineConfig::from_str(yaml).unwrap_err();
495 let text = err.to_string();
496 assert!(text.contains("pipeline.io_threads"), "{text}");
497 }
498
499 #[test]
500 fn validation_rules() {
501 let cases = [
502 (
503 "pipeline: { name: ' ' }\nsource: { m: {} }\nsink: { m: {} }",
504 "pipeline.name",
505 ),
506 (
507 "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
508 "io_threads",
509 ),
510 (
511 "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
512 "threads",
513 ),
514 (
515 "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
516 "interval",
517 ),
518 (
519 "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
520 "at least 100ms",
521 ),
522 (
523 "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
524 "max_pending_batches",
525 ),
526 (
527 "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
528 "drain_timeout",
529 ),
530 (
531 "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
532 "stalled_fail_after",
533 ),
534 (
535 "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
536 "max_inflight_bytes",
537 ),
538 (
539 "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
540 "low_ratio",
541 ),
542 (
543 "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
544 "high_ratio",
545 ),
546 ];
547 for (yaml, needle) in cases {
548 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
549 assert!(err.contains(needle), "expected `{needle}` in: {err}");
550 }
551 }
552
553 #[test]
554 fn missing_required_sections_error_clearly() {
555 let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
556 .unwrap_err()
557 .to_string();
558 assert!(err.contains("source"), "{err}");
559 let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
560 .unwrap_err()
561 .to_string();
562 assert!(err.contains("pipeline"), "{err}");
563 }
564
565 #[test]
566 fn interpolation_failures_surface_with_position() {
567 let err = PipelineConfig::from_str("pipeline:\n name: ${UNSET_VAR_FOR_TEST}\n")
568 .unwrap_err()
569 .to_string();
570 assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
571 assert!(err.contains("line 2"), "{err}");
572 }
573
574 #[test]
575 fn from_path_reads_interpolates_and_reports_io_errors() {
576 use std::io::Write as _;
577 let mut file = tempfile::NamedTempFile::new().unwrap();
578 write!(
579 file,
580 "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
581 )
582 .unwrap();
583 let cfg = PipelineConfig::from_path(file.path()).unwrap();
584 assert_eq!(cfg.pipeline.name, "from-file");
585
586 let err = PipelineConfig::from_path(Path::new("/nonexistent/etl.yaml")).unwrap_err();
587 assert!(matches!(err, ConfigError::Io { .. }));
588 assert!(err.to_string().contains("/nonexistent/etl.yaml"));
589 }
590
591 #[test]
592 fn equal_inputs_parse_to_equal_configs() {
593 let a = PipelineConfig::from_str(MINIMAL).unwrap();
594 let b = PipelineConfig::from_str(MINIMAL).unwrap();
595 assert_eq!(a, b);
596 let c = PipelineConfig::from_str(
597 "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
598 )
599 .unwrap();
600 assert_ne!(a, c);
601 }
602}