Skip to main content

spate_core/config/
mod.rs

1//! Pipeline configuration: typed framework sections plus opaque
2//! per-component passthrough, loaded from YAML with `${VAR:-default}`
3//! environment interpolation.
4//!
5//! The framework owns the typed sections (`pipeline`, `checkpoint`,
6//! `backpressure`, `metrics`) and validates them strictly
7//! (`deny_unknown_fields` at every level). The `source`, `deserializer`,
8//! and `sink` sections are single-key mappings selecting a component type;
9//! their bodies are opaque [`ComponentConfig`]s handed to the component's
10//! factory, which deserializes its own typed config. This keeps connectors
11//! fully decoupled from the framework schema.
12//!
13//! ```yaml
14//! pipeline: { name: orders, threads: 4, io_threads: 2 }
15//! checkpoint: { interval: 5s, max_pending_batches: 1024 }
16//! backpressure: { max_inflight_bytes: 256MiB }
17//! source:
18//!   kafka:                                   # KafkaSourceConfig
19//!     brokers: ${KAFKA_BROKERS:-localhost:9092}
20//!     topic: orders
21//!     group_id: orders-etl                   # required (no default)
22//! deserializer:
23//!   avro:                                    # AvroSettings (confluent mode)
24//!     registry:
25//!       url: ${SCHEMA_REGISTRY_URL:?schema registry required}
26//! sink:
27//!   clickhouse:                              # ClickHouseSinkConfig
28//!     table: orders_local
29//!     columns: [id, amount, ts]              # required; order is the wire contract
30//!     shards:
31//!       - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
32//! metrics: { exporter: prometheus, listen: 0.0.0.0:9090 }
33//! ```
34//!
35//! Environment interpolation runs on the raw text before parsing — see
36//! [`interpolate`](self::interpolate::interpolate_with) for the exact
37//! semantics of `${VAR}`, `${VAR:-default}`, `${VAR:?message}`, and `$$`.
38//!
39//! # Example
40//!
41//! ```
42//! use spate_core::config::PipelineConfig;
43//!
44//! let cfg = PipelineConfig::from_str(r#"
45//! pipeline: { name: demo }
46//! source: { memory: {} }
47//! sink: { memory: {} }
48//! "#).unwrap();
49//!
50//! assert_eq!(cfg.pipeline.name, "demo");
51//! assert_eq!(cfg.pipeline.io_threads, 2);                    // default
52//! assert_eq!(cfg.checkpoint.max_pending_batches, 1024);      // default
53//! assert_eq!(cfg.source.type_tag(), "memory");
54//! ```
55
56mod chunk;
57mod component;
58mod error;
59mod interpolate;
60
61pub use component::ComponentConfig;
62pub use error::ConfigError;
63
64/// Re-export of `serde_yaml::Value`, the opaque body type carried by a
65/// [`ComponentConfig`]. `serde_yaml` is a 0.x dependency, so exposing its
66/// `Value` directly in `spate-core`'s public API would tie our semver to
67/// theirs; this alias is the documented exemption (mirroring the [`bytes`]
68/// and `AvroValue` re-export pattern — see `docs/DESIGN.md` § Dependency
69/// policy). A major bump of the YAML crate becomes a breaking change here,
70/// and only here.
71///
72/// [`bytes`]: crate::bytes
73pub use serde_yaml::Value as YamlValue;
74
75use bytesize::ByteSize;
76use serde::Deserialize;
77use std::collections::BTreeMap;
78use std::net::SocketAddr;
79use std::path::Path;
80use std::time::Duration;
81
82/// Root of a pipeline's configuration file.
83///
84/// One process runs one pipeline; one file configures one process.
85#[derive(Debug, PartialEq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct PipelineConfig {
88    /// Identity and thread budget.
89    pub pipeline: PipelineSection,
90    /// Watermark commit policy.
91    #[serde(default)]
92    pub checkpoint: CheckpointSection,
93    /// In-flight budget and pause/resume hysteresis.
94    #[serde(default)]
95    pub backpressure: BackpressureSection,
96    /// Exporter selection and observability knobs.
97    #[serde(default)]
98    pub metrics: MetricsSection,
99    /// The source component (opaque body).
100    pub source: ComponentConfig,
101    /// Optional deserializer component (opaque body). Sources that emit
102    /// ready-made records need none.
103    #[serde(default)]
104    pub deserializer: Option<ComponentConfig>,
105    /// Single sink component (opaque body) — sugar for the common one-sink
106    /// case, addressed as `"default"`. Mutually exclusive with `sinks`.
107    /// Resolve via [`sink_config`](Self::sink_config).
108    #[serde(default)]
109    pub sink: Option<ComponentConfig>,
110    /// Named sinks for a multi-sink split: a `name -> component` map, each an
111    /// ordinary single-key component (`clickhouse: {...}`). Mutually exclusive
112    /// with `sink`. Resolve via [`sink_config`](Self::sink_config).
113    #[serde(default)]
114    pub sinks: Option<BTreeMap<String, ComponentConfig>>,
115}
116
117/// `pipeline:` — identity and thread budget.
118#[derive(Debug, PartialEq, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct PipelineSection {
121    /// Pipeline name; the `pipeline` label on every metric.
122    pub name: String,
123    /// Pinned pipeline thread count. `None` derives from
124    /// `available_parallelism` minus the I/O reserve at startup.
125    #[serde(default)]
126    pub threads: Option<usize>,
127    /// Worker threads for the I/O runtime (sink workers, checkpointer,
128    /// admin server).
129    #[serde(default = "defaults::io_threads")]
130    pub io_threads: usize,
131    /// Core-pinning mode for pipeline threads.
132    #[serde(default)]
133    pub pinning: PinningMode,
134}
135
136/// How pipeline threads are pinned to cores.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
138#[serde(rename_all = "snake_case")]
139#[non_exhaustive]
140pub enum PinningMode {
141    /// No pinning (default). Correct choice unless the pod has exclusive
142    /// cores (Kubernetes static CPU manager + Guaranteed QoS).
143    #[default]
144    Off,
145    /// Pin thread *i* to core *i*.
146    Compact,
147}
148
149/// `checkpoint:` — watermark commit policy.
150#[derive(Debug, PartialEq, Deserialize)]
151#[serde(deny_unknown_fields, default)]
152pub struct CheckpointSection {
153    /// How often committable watermarks are flushed to the source.
154    #[serde(with = "humantime_serde")]
155    pub interval: Duration,
156    /// Maximum unacknowledged batches per partition before that partition
157    /// is paused (doubles as a backpressure trigger).
158    pub max_pending_batches: usize,
159    /// Shutdown/rebalance drain budget. Must be comfortably below the pod's
160    /// `terminationGracePeriodSeconds`.
161    #[serde(with = "humantime_serde")]
162    pub drain_timeout: Duration,
163    /// A partition watermark stalled behind a failed batch for longer than
164    /// this fails the pipeline. Failed batches only stall watermarks
165    /// permanently (their data replays after restart), so this converts a
166    /// permanent sink failure — a dropped table, revoked credentials — into
167    /// a clean `Failed` exit and a restart instead of a process that runs on
168    /// forever, consuming the source but committing nothing for that
169    /// partition.
170    #[serde(with = "humantime_serde")]
171    pub stalled_fail_after: Duration,
172}
173
174impl Default for CheckpointSection {
175    fn default() -> Self {
176        CheckpointSection {
177            interval: Duration::from_secs(5),
178            max_pending_batches: 1024,
179            drain_timeout: Duration::from_secs(25),
180            stalled_fail_after: Duration::from_secs(120),
181        }
182    }
183}
184
185/// `backpressure:` — in-flight budget and hysteresis.
186#[derive(Debug, PartialEq, Deserialize)]
187#[serde(deny_unknown_fields, default)]
188pub struct BackpressureSection {
189    /// Global cap on bytes admitted into the pipeline but not yet durably
190    /// written.
191    pub max_inflight_bytes: ByteSize,
192    /// Fraction of the budget at which sources are paused.
193    pub high_ratio: f64,
194    /// Fraction of the budget below which sources may resume.
195    pub low_ratio: f64,
196    /// Minimum pause duration before resuming (avoids flapping; pausing a
197    /// Kafka partition purges its prefetch, so resume is not free).
198    #[serde(with = "humantime_serde")]
199    pub min_pause: Duration,
200}
201
202impl Default for BackpressureSection {
203    fn default() -> Self {
204        BackpressureSection {
205            max_inflight_bytes: ByteSize::mib(256),
206            high_ratio: 0.8,
207            low_ratio: 0.5,
208            min_pause: Duration::from_millis(500),
209        }
210    }
211}
212
213/// `metrics:` — exporter selection and observability knobs.
214#[derive(Debug, PartialEq, Deserialize)]
215#[serde(deny_unknown_fields, default)]
216pub struct MetricsSection {
217    /// Which exporter to install.
218    pub exporter: MetricsExporter,
219    /// Admin server bind address (`/metrics`, `/healthz`, `/readyz`).
220    pub listen: SocketAddr,
221    /// Emit per-partition gauge series (`partition` label). Off by default:
222    /// cardinality grows with the assignment.
223    pub per_partition_detail: bool,
224    /// Time basis for `spate_e2e_latency_seconds`.
225    pub e2e_basis: E2eBasis,
226}
227
228impl Default for MetricsSection {
229    fn default() -> Self {
230        MetricsSection {
231            exporter: MetricsExporter::Prometheus,
232            listen: SocketAddr::from(([0, 0, 0, 0], 9090)),
233            per_partition_detail: false,
234            e2e_basis: E2eBasis::Ingest,
235        }
236    }
237}
238
239/// Metrics exporter selection.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
241#[serde(rename_all = "snake_case")]
242#[non_exhaustive]
243pub enum MetricsExporter {
244    /// Prometheus scrape endpoint on the admin server (default).
245    #[default]
246    Prometheus,
247    /// No exporter (metrics recorded to a no-op recorder).
248    None,
249}
250
251/// Which timestamp anchors end-to-end latency.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
253#[serde(rename_all = "snake_case")]
254#[non_exhaustive]
255pub enum E2eBasis {
256    /// Framework ingest time — immune to producer clock skew (default).
257    #[default]
258    Ingest,
259    /// Record event time (e.g. Kafka message timestamp) — measures true
260    /// pipeline lag but is sensitive to upstream clocks.
261    Event,
262}
263
264mod defaults {
265    pub(super) fn io_threads() -> usize {
266        2
267    }
268}
269
270impl PipelineConfig {
271    /// Load from YAML text: interpolate `${VAR}` forms against the process
272    /// environment, parse, and validate.
273    // An inherent `from_str` (rather than `std::str::FromStr`) keeps the
274    // call site `PipelineConfig::from_str(text)?` working without a trait
275    // import, matching `from_path` beside it.
276    #[expect(
277        clippy::should_implement_trait,
278        reason = "paired with from_path; no trait import required at call sites"
279    )]
280    pub fn from_str(text: &str) -> Result<Self, ConfigError> {
281        let interpolated = interpolate::interpolate(text)?;
282        Self::parse_interpolated(&interpolated)
283    }
284
285    /// Load from a YAML file (read, interpolate, parse, validate).
286    pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
287        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
288            path: path.to_owned(),
289            source,
290        })?;
291        Self::from_str(&text)
292    }
293
294    fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
295        let de = serde_yaml::Deserializer::from_str(text);
296        let mut cfg: PipelineConfig =
297            serde_path_to_error::deserialize(de).map_err(|e| ConfigError::Parse {
298                path: e.path().to_string(),
299                source: e.into_inner(),
300            })?;
301        cfg.source.set_section("source");
302        if let Some(sink) = cfg.sink.as_mut() {
303            sink.set_section("sink");
304        }
305        if let Some(sinks) = cfg.sinks.as_mut() {
306            for sink in sinks.values_mut() {
307                sink.set_section("sink");
308            }
309        }
310        if let Some(deser) = cfg.deserializer.as_mut() {
311            deser.set_section("deserializer");
312        }
313        cfg.validate()?;
314        Ok(cfg)
315    }
316
317    /// Cross-field validation, run automatically by the loaders. Public so
318    /// programmatically built configs (tests, `spate-test`) get the same
319    /// checks.
320    pub fn validate(&self) -> Result<(), ConfigError> {
321        let fail = |msg: String| Err(ConfigError::Validation(msg));
322
323        if self.pipeline.name.trim().is_empty() {
324            return fail("pipeline.name must not be empty".into());
325        }
326        if self.pipeline.io_threads == 0 {
327            return fail("pipeline.io_threads must be at least 1".into());
328        }
329        if self.pipeline.threads == Some(0) {
330            return fail("pipeline.threads must be at least 1 when set".into());
331        }
332        // The commit loop fires whenever `last_commit.elapsed() >= interval`,
333        // so an interval below a poll cycle commits on nearly every loop.
334        // A floor keeps sub-100ms intervals from hammering the source's
335        // offset store for no durability gain (checkpointing is not the
336        // durability boundary — the sink write is).
337        const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
338        if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
339            return fail(format!(
340                "checkpoint.interval must be at least 100ms (got {:?}): the commit \
341                 loop fires every interval, so sub-100ms intervals hammer the \
342                 source's offset store without improving durability",
343                self.checkpoint.interval
344            ));
345        }
346        if self.checkpoint.max_pending_batches == 0 {
347            return fail("checkpoint.max_pending_batches must be at least 1".into());
348        }
349        if self.checkpoint.drain_timeout.is_zero() {
350            return fail("checkpoint.drain_timeout must be greater than zero".into());
351        }
352        if self.checkpoint.stalled_fail_after.is_zero() {
353            return fail("checkpoint.stalled_fail_after must be greater than zero".into());
354        }
355        if self.backpressure.max_inflight_bytes.as_u64() == 0 {
356            return fail("backpressure.max_inflight_bytes must be greater than zero".into());
357        }
358        let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
359        if !(low > 0.0 && low < high && high <= 1.0) {
360            return fail(format!(
361                "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
362                 (got low_ratio={low}, high_ratio={high})"
363            ));
364        }
365        match (&self.sink, &self.sinks) {
366            (Some(_), Some(_)) => {
367                return fail("set exactly one of `sink:` or `sinks:`, not both".into());
368            }
369            (None, None) => {
370                return fail("a `sink:` or `sinks:` section is required".into());
371            }
372            (None, Some(map)) if map.is_empty() => {
373                return fail("`sinks:` must declare at least one sink".into());
374            }
375            _ => {}
376        }
377        if let Some(sinks) = &self.sinks {
378            for name in sinks.keys() {
379                if name.is_empty() {
380                    return fail("`sinks:` names must be non-empty".into());
381                }
382                // "default" maps to the historical component="sink" metric
383                // label, so a sink literally named "sink" would merge its
384                // series with the default's.
385                if name == "sink" {
386                    return fail(
387                        "the sink name \"sink\" is reserved (it is the default \
388                         sink's metric label); rename the `sinks:` entry"
389                            .into(),
390                    );
391                }
392            }
393        }
394        self.reject_stray_chunk()?;
395        // Resolve every declared sink's `chunk:` block now, so a malformed
396        // block is rejected at load — including for a `sinks:` entry this
397        // binary never installs (nothing later would resolve it).
398        if let Some(sink) = &self.sink {
399            sink.resolved_chunk()?;
400        }
401        if let Some(sinks) = &self.sinks {
402            for (name, sink) in sinks {
403                sink.resolved_chunk()
404                    .map_err(|e| name_sinks_entry_error(name, e))?;
405            }
406        }
407        Ok(())
408    }
409
410    /// Reject the framework-reserved `chunk:` key on a non-sink section.
411    /// `chunk:` configures the chain terminal, which only sinks have; the
412    /// framework peels the key indiscriminately, so a stray `chunk:` under
413    /// `source`/`deserializer` would otherwise be silently swallowed. Split
414    /// out of [`validate`](Self::validate) so `Pipeline::from_config` — which
415    /// deliberately skips full validation for minimal programmatic configs —
416    /// can still enforce it.
417    pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
418        if self.source.resolved_chunk()?.is_some() {
419            return Err(ConfigError::Validation(
420                "`chunk:` is only valid on a sink section, not `source`".into(),
421            ));
422        }
423        if let Some(deser) = &self.deserializer
424            && deser.resolved_chunk()?.is_some()
425        {
426            return Err(ConfigError::Validation(
427                "`chunk:` is only valid on a sink section, not `deserializer`".into(),
428            ));
429        }
430        Ok(())
431    }
432
433    /// The component config for the sink named `name`. The single-sink `sink:`
434    /// form is addressed as `"default"`. A connector factory calls this once
435    /// per sink to build it.
436    ///
437    /// # Errors
438    ///
439    /// [`ConfigError::Validation`] if no sink is configured under `name`.
440    pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
441        if let Some(sinks) = &self.sinks {
442            sinks.get(name).ok_or_else(|| {
443                let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
444                ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
445            })
446        } else if name == "default" {
447            self.sink
448                .as_ref()
449                .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
450        } else {
451            Err(ConfigError::Validation(format!(
452                "no sink named {name:?}: this pipeline configures a single `sink:` \
453                 (address it as \"default\")"
454            )))
455        }
456    }
457
458    /// The configured sink names, sorted. A single-sink config reports
459    /// `["default"]`.
460    #[must_use]
461    pub fn sink_names(&self) -> Vec<String> {
462        match &self.sinks {
463            Some(sinks) => sinks.keys().cloned().collect(),
464            None => vec!["default".to_string()],
465        }
466    }
467}
468
469/// Re-anchor a `sinks:` entry's chunk error onto its map key. The entry's
470/// section prefix is the shared `sink.<type>`, which cannot distinguish two
471/// entries of the same connector type — `sinks.<name>.<...>` can.
472fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
473    match e {
474        ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
475        ConfigError::Component { context, message } => ConfigError::Component {
476            context: match context.strip_prefix("sink.") {
477                Some(rest) => format!("sinks.{name}.{rest}"),
478                None => format!("sinks.{name}.{context}"),
479            },
480            message,
481        },
482        other => other,
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    const MINIMAL: &str = r#"
491pipeline: { name: demo }
492source: { memory: {} }
493sink: { memory: {} }
494"#;
495
496    #[test]
497    fn minimal_config_applies_documented_defaults() {
498        let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
499        assert_eq!(cfg.pipeline.name, "demo");
500        assert_eq!(cfg.pipeline.threads, None);
501        assert_eq!(cfg.pipeline.io_threads, 2);
502        assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
503        assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
504        assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
505        assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
506        assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
507        assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
508        assert_eq!(cfg.backpressure.high_ratio, 0.8);
509        assert_eq!(cfg.backpressure.low_ratio, 0.5);
510        assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
511        assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
512        assert_eq!(cfg.metrics.listen, SocketAddr::from(([0, 0, 0, 0], 9090)));
513        assert!(!cfg.metrics.per_partition_detail);
514        assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
515        assert!(cfg.deserializer.is_none());
516    }
517
518    #[test]
519    fn sink_chunk_block_parses_and_resolves() {
520        let yaml = r#"
521pipeline: { name: demo }
522source: { memory: {} }
523sink:
524  memory:
525    chunk: { target_bytes: 512KiB, encode_policy: fail }
526"#;
527        let cfg = PipelineConfig::from_str(yaml).unwrap();
528        let chunk = cfg
529            .sink_config("default")
530            .unwrap()
531            .resolved_chunk()
532            .unwrap()
533            .expect("chunk present");
534        assert_eq!(chunk.target_bytes, 512 * 1024);
535        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
536    }
537
538    #[test]
539    fn chunk_on_a_source_section_is_rejected() {
540        let yaml = r#"
541pipeline: { name: demo }
542source:
543  memory:
544    chunk: { target_bytes: 64KiB }
545sink: { memory: {} }
546"#;
547        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
548        assert!(err.contains("chunk"), "{err}");
549        assert!(err.contains("source"), "{err}");
550    }
551
552    #[test]
553    fn zero_target_bytes_in_yaml_is_rejected_at_load() {
554        let yaml = r#"
555pipeline: { name: demo }
556source: { memory: {} }
557sink:
558  memory:
559    chunk: { target_bytes: 0B }
560"#;
561        // `validate` resolves every declared sink chunk, so the loader itself
562        // rejects it — "rejected at load" in the reference docs is literal.
563        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
564        assert!(err.contains("chunk.target_bytes"), "{err}");
565    }
566
567    #[test]
568    fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
569        // Two entries of the same connector type share the dotted path
570        // `sink.memory.…`, so the error must be re-anchored on the map key —
571        // and an entry must fail at load even if no binary ever installs it.
572        let yaml = r#"
573pipeline: { name: demo }
574source: { memory: {} }
575sinks:
576  hot: { memory: {} }
577  cold:
578    memory:
579      chunk: { encode_policy: retry }
580"#;
581        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
582        assert!(err.contains("sinks.cold"), "{err}");
583        let yaml = r#"
584pipeline: { name: demo }
585source: { memory: {} }
586sinks:
587  cold:
588    memory:
589      chunk: { target_bytes: 0B }
590"#;
591        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
592        assert!(err.contains("sinks.cold"), "{err}");
593        assert!(err.contains("chunk.target_bytes"), "{err}");
594    }
595
596    #[test]
597    fn full_design_doc_example_parses() {
598        // The connector bodies below mirror the module-doc example and use the
599        // real connector field names. spate-core has no dependency on the
600        // connector crates, so this test can only parse the framework layer;
601        // the connector body shapes are verified by hand against
602        // crates/spate-kafka/src/config.rs (brokers/topic/group_id),
603        // crates/spate-avro/src/config.rs (registry.url), and
604        // crates/spate-clickhouse/src/config.rs (table/columns/shards) — and
605        // stay consistent with crates/spate/examples/kafka_avro_to_clickhouse.yaml.
606        let yaml = r#"
607pipeline: { name: orders, threads: 4, io_threads: 2 }
608checkpoint: { interval: 5s, max_pending_batches: 1024 }
609backpressure: { max_inflight_bytes: 256MiB }
610source:
611  kafka:
612    brokers: ${KAFKA_BROKERS:-localhost:9092}
613    topic: orders
614    group_id: orders-etl
615    rdkafka: { fetch.message.max.bytes: "1048576" }
616deserializer:
617  avro:
618    mode: confluent
619    registry:
620      url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
621sink:
622  clickhouse:
623    table: orders_local
624    columns: [id, amount, ts]
625    shards:
626      - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
627      - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
628    batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
629    inflight: { max_per_shard: 2 }
630    retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
631metrics: { exporter: prometheus, listen: 0.0.0.0:9090 }
632"#;
633        let cfg = PipelineConfig::from_str(yaml).unwrap();
634        assert_eq!(cfg.pipeline.threads, Some(4));
635        assert_eq!(cfg.source.type_tag(), "kafka");
636        assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
637        assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
638
639        // Interpolated default landed inside the opaque body, and the kafka
640        // body carries the required group_id.
641        #[derive(Debug, serde::Deserialize)]
642        struct KafkaProbe {
643            brokers: String,
644            group_id: String,
645            #[serde(flatten)]
646            _rest: serde_yaml::Value,
647        }
648        let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
649        assert_eq!(kafka.brokers, "localhost:9092");
650        assert_eq!(kafka.group_id, "orders-etl");
651
652        // The avro body uses the nested `registry.url` shape, and the
653        // clickhouse body carries the required `columns`.
654        #[derive(Debug, serde::Deserialize)]
655        struct AvroProbe {
656            registry: RegistryProbe,
657        }
658        #[derive(Debug, serde::Deserialize)]
659        struct RegistryProbe {
660            url: String,
661        }
662        let avro: AvroProbe = cfg
663            .deserializer
664            .as_ref()
665            .unwrap()
666            .deserialize_into()
667            .unwrap();
668        assert_eq!(avro.registry.url, "http://sr:8081");
669
670        #[derive(Debug, serde::Deserialize)]
671        struct ChProbe {
672            columns: Vec<String>,
673        }
674        let ch: ChProbe = cfg
675            .sink_config("default")
676            .unwrap()
677            .deserialize_into()
678            .unwrap();
679        assert_eq!(ch.columns, ["id", "amount", "ts"]);
680    }
681
682    #[test]
683    fn single_sink_resolves_as_default() {
684        let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
685        assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
686        assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
687        assert!(cfg.sink_config("other").is_err());
688    }
689
690    #[test]
691    fn sinks_map_parses_and_resolves_by_name() {
692        let yaml = r#"
693pipeline: { name: demo }
694source: { memory: {} }
695sinks:
696  type_a: { memory: {} }
697  type_b: { memory: {} }
698"#;
699        let cfg = PipelineConfig::from_str(yaml).unwrap();
700        assert_eq!(
701            cfg.sink_names(),
702            vec!["type_a".to_string(), "type_b".to_string()]
703        );
704        assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
705        assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
706        // The single-sink alias is not present in a `sinks:` config.
707        assert!(cfg.sink_config("default").is_err());
708    }
709
710    #[test]
711    fn sink_and_sinks_are_mutually_exclusive() {
712        let both = r#"
713pipeline: { name: demo }
714source: { memory: {} }
715sink: { memory: {} }
716sinks:
717  a: { memory: {} }
718"#;
719        assert!(matches!(
720            PipelineConfig::from_str(both),
721            Err(ConfigError::Validation(_))
722        ));
723
724        let neither = r#"
725pipeline: { name: demo }
726source: { memory: {} }
727"#;
728        assert!(matches!(
729            PipelineConfig::from_str(neither),
730            Err(ConfigError::Validation(_))
731        ));
732
733        let empty = r#"
734pipeline: { name: demo }
735source: { memory: {} }
736sinks: {}
737"#;
738        assert!(matches!(
739            PipelineConfig::from_str(empty),
740            Err(ConfigError::Validation(_))
741        ));
742    }
743
744    #[test]
745    fn reserved_sink_names_are_rejected() {
746        // "sink" is the default sink's metric label; a `sinks:` entry under
747        // that name would merge its series with a default sink's.
748        let reserved = r#"
749pipeline: { name: demo }
750source: { memory: {} }
751sinks:
752  sink: { memory: {} }
753"#;
754        assert!(matches!(
755            PipelineConfig::from_str(reserved),
756            Err(ConfigError::Validation(msg)) if msg.contains("reserved")
757        ));
758
759        let empty_name = r#"
760pipeline: { name: demo }
761source: { memory: {} }
762sinks:
763  "": { memory: {} }
764"#;
765        assert!(matches!(
766            PipelineConfig::from_str(empty_name),
767            Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
768        ));
769    }
770
771    #[test]
772    fn unknown_fields_are_rejected_at_every_typed_level() {
773        for (yaml, field) in [
774            (
775                "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
776                "bogus",
777            ),
778            (
779                "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
780                "intervall",
781            ),
782            (
783                "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
784                "port",
785            ),
786            (
787                "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
788                "sinks",
789            ),
790        ] {
791            let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
792            assert!(err.contains(field), "expected `{field}` in error: {err}");
793        }
794    }
795
796    #[test]
797    fn parse_errors_carry_the_yaml_path() {
798        let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
799        let err = PipelineConfig::from_str(yaml).unwrap_err();
800        let text = err.to_string();
801        assert!(text.contains("pipeline.io_threads"), "{text}");
802    }
803
804    #[test]
805    fn validation_rules() {
806        let cases = [
807            (
808                "pipeline: { name: '  ' }\nsource: { m: {} }\nsink: { m: {} }",
809                "pipeline.name",
810            ),
811            (
812                "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
813                "io_threads",
814            ),
815            (
816                "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
817                "threads",
818            ),
819            (
820                "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
821                "interval",
822            ),
823            (
824                "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
825                "at least 100ms",
826            ),
827            (
828                "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
829                "max_pending_batches",
830            ),
831            (
832                "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
833                "drain_timeout",
834            ),
835            (
836                "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
837                "stalled_fail_after",
838            ),
839            (
840                "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
841                "max_inflight_bytes",
842            ),
843            (
844                "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
845                "low_ratio",
846            ),
847            (
848                "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
849                "high_ratio",
850            ),
851        ];
852        for (yaml, needle) in cases {
853            let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
854            assert!(err.contains(needle), "expected `{needle}` in: {err}");
855        }
856    }
857
858    #[test]
859    fn missing_required_sections_error_clearly() {
860        let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
861            .unwrap_err()
862            .to_string();
863        assert!(err.contains("source"), "{err}");
864        let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
865            .unwrap_err()
866            .to_string();
867        assert!(err.contains("pipeline"), "{err}");
868    }
869
870    #[test]
871    fn interpolation_failures_surface_with_position() {
872        let err = PipelineConfig::from_str("pipeline:\n  name: ${UNSET_VAR_FOR_TEST}\n")
873            .unwrap_err()
874            .to_string();
875        assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
876        assert!(err.contains("line 2"), "{err}");
877    }
878
879    #[test]
880    fn from_path_reads_interpolates_and_reports_io_errors() {
881        use std::io::Write as _;
882        let mut file = tempfile::NamedTempFile::new().unwrap();
883        write!(
884            file,
885            "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
886        )
887        .unwrap();
888        let cfg = PipelineConfig::from_path(file.path()).unwrap();
889        assert_eq!(cfg.pipeline.name, "from-file");
890
891        let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
892        assert!(matches!(err, ConfigError::Io { .. }));
893        assert!(err.to_string().contains("/nonexistent/spate.yaml"));
894    }
895
896    #[test]
897    fn equal_inputs_parse_to_equal_configs() {
898        let a = PipelineConfig::from_str(MINIMAL).unwrap();
899        let b = PipelineConfig::from_str(MINIMAL).unwrap();
900        assert_eq!(a, b);
901        let c = PipelineConfig::from_str(
902            "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
903        )
904        .unwrap();
905        assert_ne!(a, c);
906    }
907}