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 (`admin`, `backpressure`,
6//! `checkpoint`, `metrics`, `pipeline`) 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. The optional
11//! `feed` section is a [`FeedSection`] naming the work feed a feed-reading
12//! source reads.
13//!
14//! ```yaml
15//! pipeline: { name: orders, threads: 4, io_threads: 2 }
16//! checkpoint: { interval: 5s, max_pending_batches: 1024 }
17//! backpressure: { max_inflight_bytes: 256MiB }
18//! source:
19//!   kafka:                                   # KafkaSourceConfig
20//!     brokers: ${KAFKA_BROKERS:-localhost:9092}
21//!     topic: orders
22//!     group_id: orders-etl                   # required (no default)
23//! deserializer:
24//!   avro:                                    # AvroSettings (confluent mode)
25//!     registry:
26//!       url: ${SCHEMA_REGISTRY_URL:?schema registry required}
27//! sink:
28//!   clickhouse:                              # ClickHouseSinkConfig
29//!     table: orders_local
30//!     shards:
31//!       - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
32//! admin: { listen: 0.0.0.0:9090 }           # /metrics, /healthz, /readyz
33//! metrics: { exporter: prometheus }
34//! ```
35//!
36//! Environment interpolation runs on the raw text before parsing. See
37//! `interpolate_with` for the exact semantics of `${VAR}`,
38//! `${VAR:-default}`, `${VAR:?message}`, and `$$`.
39//!
40//! Every framework section here is `#[non_exhaustive]`. Build a config with
41//! [`PipelineConfig::new`] or [`PipelineConfig::new_multi_sink`], a
42//! [`PipelineSection`] with [`PipelineSection::new`], and the optional
43//! sections with `default()`, then assign the fields you are setting; a key
44//! added later arrives as a new default and existing code keeps compiling.
45//!
46//! # Example
47//!
48//! ```
49//! use spate_core::config::PipelineConfig;
50//!
51//! let cfg = PipelineConfig::from_str(r#"
52//! pipeline: { name: demo }
53//! source: { memory: {} }
54//! sink: { memory: {} }
55//! "#).unwrap();
56//!
57//! assert_eq!(cfg.pipeline.name, "demo");
58//! assert_eq!(cfg.pipeline.io_threads, 2);                    // default
59//! assert_eq!(cfg.checkpoint.max_pending_batches, 1024);      // default
60//! assert_eq!(cfg.source.type_tag(), "memory");
61//! ```
62
63mod 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
74/// Re-export of `serde_yaml::Value`, the opaque body type carried by a
75/// [`ComponentConfig`]. `serde_yaml` is a 0.x dependency, so exposing its
76/// `Value` directly in `spate-core`'s public API would tie our semver to
77/// theirs; this alias is the documented exemption (mirroring the [`bytes`]
78/// and `AvroValue` re-export pattern, INV-6).
79/// A major bump of the YAML crate becomes a breaking change here,
80/// and only here.
81///
82/// [`bytes`]: crate::bytes
83pub 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/// Root of a pipeline's configuration file.
95///
96/// One process runs one pipeline; one file configures one process.
97///
98/// Construct with [`PipelineConfig::new`], or
99/// [`PipelineConfig::new_multi_sink`] for a `sinks:` map, and set the optional
100/// fields. The struct is `#[non_exhaustive]` so new sections can be added
101/// without breaking callers.
102#[derive(Debug, PartialEq, Deserialize)]
103#[serde(deny_unknown_fields)]
104#[non_exhaustive]
105pub struct PipelineConfig {
106    /// Identity and thread budget.
107    pub pipeline: PipelineSection,
108    /// The admin server carrying `/metrics`, `/healthz` and `/readyz`.
109    #[serde(default)]
110    pub admin: AdminSection,
111    /// In-flight budget and pause/resume hysteresis.
112    #[serde(default)]
113    pub backpressure: BackpressureSection,
114    /// Watermark commit policy.
115    #[serde(default)]
116    pub checkpoint: CheckpointSection,
117    /// Exporter selection and observability knobs.
118    #[serde(default)]
119    pub metrics: MetricsSection,
120    /// The source component (opaque body).
121    pub source: ComponentConfig,
122    /// Optional deserializer component (opaque body). Sources that emit
123    /// ready-made records need none.
124    #[serde(default)]
125    pub deserializer: Option<ComponentConfig>,
126    /// Single sink component (opaque body), sugar for the common one-sink
127    /// case, addressed as `"default"`. Mutually exclusive with `sinks`.
128    /// Resolve via [`sink_config`](Self::sink_config).
129    #[serde(default)]
130    pub sink: Option<ComponentConfig>,
131    /// Named sinks for a multi-sink split: a `name -> component` map, each an
132    /// ordinary single-key component (`clickhouse: {...}`). Mutually exclusive
133    /// with `sink`. Resolve via [`sink_config`](Self::sink_config).
134    #[serde(default)]
135    pub sinks: Option<BTreeMap<String, ComponentConfig>>,
136    /// The work feed a feed-reading source reads, and the keys every feed
137    /// backend shares. `None` hands the source no feed.
138    #[serde(default)]
139    pub feed: Option<FeedSection>,
140}
141
142/// Identity and thread budget (`pipeline:`).
143///
144/// Construct with [`PipelineSection::new`] and set the optional fields. The
145/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
146/// callers.
147#[derive(Debug, PartialEq, Deserialize)]
148#[serde(deny_unknown_fields)]
149#[non_exhaustive]
150pub struct PipelineSection {
151    /// Pipeline name; the `pipeline` label on every metric.
152    pub name: String,
153    /// Pinned pipeline thread count. `None` derives it at startup from
154    /// `available_parallelism`, minus one core for every two `io_threads`
155    /// (rounded up) and one for the controller, at least 1.
156    #[serde(default)]
157    pub threads: Option<usize>,
158    /// Worker threads for the I/O runtime (sink workers, admin server).
159    /// While `threads` is unset, each odd-numbered worker (the first, the
160    /// third, and so on) takes one core from the derived pipeline thread
161    /// count, down to its floor of 1.
162    #[serde(default = "defaults::io_threads")]
163    pub io_threads: usize,
164    /// Core-pinning mode for pipeline threads.
165    #[serde(default)]
166    pub pinning: PinningMode,
167}
168
169impl PipelineSection {
170    /// A section named `name`. Every other field starts at its YAML default.
171    ///
172    /// ```
173    /// use spate_core::config::PipelineSection;
174    ///
175    /// let mut pipeline = PipelineSection::new("orders");
176    /// pipeline.io_threads = 4;
177    ///
178    /// assert_eq!(pipeline.name, "orders");
179    /// assert_eq!(pipeline.threads, None);
180    /// ```
181    #[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/// How pipeline threads are pinned to cores.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum PinningMode {
197    /// No pinning (default). Correct choice unless the pod has exclusive
198    /// cores (Kubernetes static CPU manager + Guaranteed QoS).
199    #[default]
200    Off,
201    /// Pin thread *i* to core *i*.
202    Compact,
203}
204
205/// Watermark commit policy (`checkpoint:`).
206///
207/// Construct with [`CheckpointSection::default`] and set the fields. The
208/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
209/// callers.
210#[derive(Debug, PartialEq, Deserialize)]
211#[serde(deny_unknown_fields, default)]
212#[non_exhaustive]
213pub struct CheckpointSection {
214    /// How often committable watermarks are flushed to the source.
215    #[serde(with = "humantime_serde")]
216    pub interval: Duration,
217    /// Hard per-partition ceiling on unacknowledged batches. A partition at
218    /// the ceiling has its lanes skipped at the poll boundary until
219    /// acknowledgments retire batches. No pause is involved, and other
220    /// partitions are unaffected. Bounds tracker memory and the replay a
221    /// stalled partition can accumulate.
222    pub max_pending_batches: usize,
223    /// Shutdown/rebalance drain budget. Must be comfortably below the pod's
224    /// `terminationGracePeriodSeconds`, and more than
225    /// [`STOP_RESERVE`](crate::feed::STOP_RESERVE) with a feed that
226    /// [hands back](crate::feed::Feed::hands_back).
227    #[serde(with = "humantime_serde")]
228    pub drain_timeout: Duration,
229    /// A partition watermark stalled behind a failed batch for longer than
230    /// this fails the pipeline. Failed batches only stall watermarks
231    /// permanently (their data replays after restart), so this converts a
232    /// permanent sink failure (a dropped table, revoked credentials) into a
233    /// clean `Failed` exit and a restart instead of a process that runs on
234    /// forever, consuming the source but committing nothing for that
235    /// partition.
236    #[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/// In-flight budget and hysteresis (`backpressure:`).
252///
253/// Construct with [`BackpressureSection::default`] and set the fields. The
254/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
255/// callers.
256#[derive(Debug, PartialEq, Deserialize)]
257#[serde(deny_unknown_fields, default)]
258#[non_exhaustive]
259pub struct BackpressureSection {
260    /// Global cap on bytes admitted into the pipeline but not yet durably
261    /// written.
262    pub max_inflight_bytes: ByteSize,
263    /// Fraction of the budget at which sources are paused.
264    pub high_ratio: f64,
265    /// Fraction of the budget below which sources may resume.
266    pub low_ratio: f64,
267    /// Minimum pause duration before resuming (avoids flapping; pausing a
268    /// Kafka partition purges its prefetch, so resume is not free).
269    #[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/// The HTTP server carrying `/metrics`, `/healthz` and `/readyz` (`admin:`).
285///
286/// One server serves all three. The probes answer regardless of
287/// [`MetricsSection::exporter`]. `/metrics` is served only where this
288/// pipeline's own handle renders an exposition. `exporter: none` leaves it a
289/// 404, and so does a recorder another library installed first, which this
290/// pipeline records into but cannot render.
291///
292/// Construct with [`AdminSection::default`] and set the fields. The struct is
293/// `#[non_exhaustive]` so new knobs can be added without breaking callers.
294#[derive(Debug, PartialEq, Deserialize)]
295#[serde(deny_unknown_fields, default)]
296#[non_exhaustive]
297pub struct AdminSection {
298    /// Bind address, or `none` for no server at all.
299    ///
300    /// With no server the probes and the exposition are unreachable over
301    /// HTTP. The exposition is still readable in-process through
302    /// [`Pipeline::metrics`](crate::pipeline::Pipeline::metrics), which is
303    /// what an embedding program mounting it on its own server uses.
304    ///
305    /// A port of `0` asks the kernel to pick a free one, and the start logs
306    /// the address the server bound, at `INFO`. [Monitoring][mon] names the
307    /// message it goes out under.
308    ///
309    /// [mon]: https://spate.kainth.dev/docs/user-guide/deployment/monitoring
310    #[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
322/// Deserialize a bind address or the literal `none`.
323///
324/// Takes the value through a visitor rather than through `String` so that a
325/// YAML null (`~`, `null`, or a bare `listen:`) reports the two accepted
326/// spellings instead of a type mismatch against an intermediate this key does
327/// not otherwise have.
328fn 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/// Exporter selection and observability knobs (`metrics:`).
357///
358/// Construct with [`MetricsSection::default`] and set the fields. The struct
359/// is `#[non_exhaustive]` so new knobs can be added without breaking callers.
360#[derive(Debug, PartialEq, Deserialize)]
361#[serde(deny_unknown_fields, default)]
362#[non_exhaustive]
363pub struct MetricsSection {
364    /// Which exporter to install.
365    pub exporter: MetricsExporter,
366    /// Emit per-partition gauge series (`partition` label). Off by default:
367    /// cardinality grows with the assignment.
368    pub per_partition_detail: bool,
369    /// Time basis for `spate_e2e_latency_seconds`.
370    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/// Metrics exporter selection.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
385#[serde(rename_all = "snake_case")]
386#[non_exhaustive]
387pub enum MetricsExporter {
388    /// Prometheus scrape endpoint on the admin server (default).
389    #[default]
390    Prometheus,
391    /// Installs no recorder, so handles record into one the process already
392    /// has, or nowhere.
393    None,
394}
395
396/// Which timestamp anchors end-to-end latency.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
398#[serde(rename_all = "snake_case")]
399#[non_exhaustive]
400pub enum E2eBasis {
401    /// Framework ingest time, immune to producer clock skew (default).
402    #[default]
403    Ingest,
404    /// Record event time (e.g. Kafka message timestamp). Measures true
405    /// pipeline lag but is sensitive to upstream clocks.
406    Event,
407}
408
409mod defaults {
410    pub(super) fn io_threads() -> usize {
411        2
412    }
413}
414
415/// Tag a component with the section it sits in, so its error paths read the
416/// same whether the config was parsed or built in code.
417fn labelled(mut component: ComponentConfig, section: &'static str) -> ComponentConfig {
418    component.set_section(section);
419    component
420}
421
422impl PipelineConfig {
423    /// A config for one source and one sink, the `sink:` form. Every optional
424    /// section starts at its YAML default, and the sink is addressed as
425    /// `"default"` by [`sink_config`](Self::sink_config).
426    ///
427    /// ```
428    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
429    /// use std::time::Duration;
430    ///
431    /// let mut pipeline = PipelineSection::new("orders");
432    /// pipeline.io_threads = 4;
433    ///
434    /// let mut cfg = PipelineConfig::new(
435    ///     pipeline,
436    ///     ComponentConfig::new("memory", YamlValue::Mapping(Default::default())),
437    ///     ComponentConfig::new("memory", YamlValue::Mapping(Default::default())),
438    /// );
439    /// cfg.checkpoint.interval = Duration::from_secs(10);
440    /// cfg.validate()?;
441    ///
442    /// assert_eq!(cfg.sink_config("default")?.type_tag(), "memory");
443    /// # Ok::<(), spate_core::config::ConfigError>(())
444    /// ```
445    #[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    /// A config for one source and a map of named sinks, the `sinks:` form.
455    /// Every optional section starts at its YAML default, and each name
456    /// addresses its sink through [`sink_config`](Self::sink_config).
457    ///
458    /// The map is not checked here. [`validate`](Self::validate) rejects an
459    /// empty map, an empty name, and the reserved name `"sink"`.
460    ///
461    /// ```
462    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
463    /// use std::collections::BTreeMap;
464    ///
465    /// let body = || YamlValue::Mapping(Default::default());
466    /// let sinks = BTreeMap::from([
467    ///     ("eu".to_owned(), ComponentConfig::new("memory", body())),
468    ///     ("us".to_owned(), ComponentConfig::new("memory", body())),
469    /// ]);
470    ///
471    /// let cfg = PipelineConfig::new_multi_sink(
472    ///     PipelineSection::new("orders"),
473    ///     ComponentConfig::new("memory", body()),
474    ///     sinks,
475    /// );
476    /// cfg.validate()?;
477    ///
478    /// assert_eq!(cfg.sink_names(), ["eu", "us"]);
479    /// # Ok::<(), spate_core::config::ConfigError>(())
480    /// ```
481    #[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    /// The same config with a deserializer component, tagged with its
495    /// section the way the constructors tag the source and the sink.
496    ///
497    /// ```
498    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
499    ///
500    /// let body = || YamlValue::Mapping(Default::default());
501    /// let cfg = PipelineConfig::new(
502    ///     PipelineSection::new("orders"),
503    ///     ComponentConfig::new("memory", body()),
504    ///     ComponentConfig::new("memory", body()),
505    /// )
506    /// .with_deserializer(ComponentConfig::new("json", body()));
507    ///
508    /// assert_eq!(
509    ///     cfg.deserializer.as_ref().map(ComponentConfig::type_tag),
510    ///     Some("json")
511    /// );
512    /// ```
513    #[must_use]
514    pub fn with_deserializer(mut self, deserializer: ComponentConfig) -> PipelineConfig {
515        self.deserializer = Some(labelled(deserializer, "deserializer"));
516        self
517    }
518
519    /// The one place the field list lives, so a section added later reaches
520    /// both constructors. Callers pass exactly one of `sink`/`sinks`.
521    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    /// Load from YAML text: interpolate `${VAR}` forms against the process
542    /// environment, parse, and validate.
543    // An inherent `from_str` (rather than `std::str::FromStr`) keeps the
544    // call site `PipelineConfig::from_str(text)?` working without a trait
545    // import, matching `from_path` beside it.
546    #[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    /// Load from a YAML file (read, interpolate, parse, validate).
556    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    /// Cross-field validation, run automatically by the loaders. Public so
592    /// programmatically built configs (tests, `spate-test`) get the same
593    /// checks.
594    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        // The commit loop fires once `interval` has passed since the last
607        // commit, so an interval below a poll cycle commits on nearly every
608        // loop.
609        // A floor keeps sub-100ms intervals from hammering the source's
610        // offset store for no durability gain. The sink write is the
611        // durability boundary, not the checkpoint.
612        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                // "default" maps to the historical component="sink" metric
658                // label, so a sink literally named "sink" would merge its
659                // series with the default's.
660                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        // Resolve every declared sink's `chunk:` block now, so a malformed
673        // block is rejected at load, including for a `sinks:` entry this
674        // binary never installs (nothing later would resolve it).
675        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    /// Reject the framework-reserved `chunk:` key on a non-sink section.
688    /// `chunk:` configures the chain terminal, which only sinks have; the
689    /// framework peels the key indiscriminately, so a stray `chunk:` under
690    /// `source`/`deserializer`, or under a `feed:` backend built in code,
691    /// would otherwise be silently swallowed. Split out of
692    /// [`validate`](Self::validate) so `Pipeline::from_config`, which skips
693    /// full validation for minimal programmatic configs, can still enforce it.
694    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    /// Reject a `location` on the in-process feed, which keeps no state
718    /// outside the process. Split out of
719    /// [`validate`](Self::validate) so the runtime, which a programmatic
720    /// config reaches without validation, enforces it too.
721    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    /// Reject a `feed.settling_window` on the in-process feed, whose exact
734    /// counts need no window, and one outside 20 s to 24 h: shorter than the
735    /// longest gap between the end check's reads, or long enough to overflow
736    /// a deadline. Split out of [`validate`](Self::validate) so the runtime,
737    /// which a programmatic config reaches without validation, enforces it
738    /// too.
739    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    /// The component config for the sink named `name`. The single-sink `sink:`
767    /// form is addressed as `"default"`. A connector factory calls this once
768    /// per sink to build it.
769    ///
770    /// # Errors
771    ///
772    /// [`ConfigError::Validation`] if no sink is configured under `name`.
773    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    /// The configured sink names, sorted. A single-sink config reports
792    /// `["default"]`.
793    #[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
802/// Re-anchor a `sinks:` entry's chunk error onto its map key. The entry's
803/// section prefix is the shared `sink.<type>`, which cannot distinguish two
804/// entries of the same connector type. `sinks.<name>.<...>` can.
805fn 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    /// `Debug` on the whole pipeline config prints no connector value.
830    #[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    /// `new` and the YAML defaults are two spellings of one config, so a
870    /// section added to the struct has to reach both.
871    #[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    /// `new_multi_sink` and the YAML defaults are two spellings of one config,
885    /// so a section added to the struct has to reach both.
886    #[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    /// A deserializer added through `with_deserializer` carries the section
911    /// tag the loader gives one, so the two spellings report the same error
912    /// paths.
913    #[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    /// A constructed config passes the validation the loaders run, so `new`
934    /// cannot produce one `from_str` would have rejected.
935    #[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        // The error names the key and both accepted forms; without the path
974        // a reader cannot tell which of several addresses in a file is wrong.
975        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        // A YAML null is not a spelling of `none`. It has to say so in the
981        // same terms as every other rejected value, or the one reader who
982        // reaches for `~` gets a type error naming a type they never wrote.
983        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    /// The bind address is `admin.listen`, and `metrics` carries no address
999    /// of its own. A file placing one there fails to load rather than parsing
1000    /// into a pipeline whose server is somewhere else.
1001    #[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    /// A `coordination:` section in any spelling fails at load, and the
1047    /// message does not echo a secret the section carried.
1048    #[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    /// Pins that the loader parses a `feed:` section, with its error paths
1067    /// rooted at `feed`.
1068    #[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        // `validate` resolves every declared sink chunk, so the loader itself
1092        // rejects it. "Rejected at load" in the reference docs is literal.
1093        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        // Two entries of the same connector type share the dotted path
1100        // `sink.memory.…`, so the error must be re-anchored on the map key,
1101        // and an entry must fail at load even if no binary ever installs it.
1102        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        // The connector bodies below mirror the module-doc example and use the
1129        // real connector field names. spate-core has no dependency on the
1130        // connector crates, so this test only parses the framework layer.
1131        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        // Interpolated default landed inside the opaque body, and the kafka
1165        // body carries the required group_id.
1166        #[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        // The avro body uses the nested `registry.url` shape, and the
1178        // clickhouse body carries its nested `shards` topology.
1179        #[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        // The single-sink alias is not present in a `sinks:` config.
1239        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        // "sink" is the default sink's metric label; a `sinks:` entry under
1279        // that name would merge its series with a default sink's.
1280        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    /// A parse error keeps its path and position and does not quote the
1337    /// value, including an explicit tag inside a connector body.
1338    /// Regression for #764.
1339    #[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}