Skip to main content

spate_core/pipeline/
builder.rs

1//! The pipeline builder: the primary assembly path.
2//!
3//! [`Pipeline::from_config`] owns startup initialization (telemetry, the
4//! metrics exporter, and the shared I/O runtime). Every metric handle built
5//! afterwards (framework or custom) binds to the process's recorder, which
6//! always exists under `exporter: prometheus`, and connectors get an I/O
7//! handle before any thread spawns. The builder is a thin composition of the public
8//! primitives it replaces; nothing here is required, and the desugaring
9//! below remains a fully supported assembly path.
10//!
11//! The shape of an assembly (illustrative; connector construction is
12//! elided, and the `spate` crate's examples carry complete, compiling
13//! binaries):
14//!
15//! ```ignore
16//! let pipeline = Pipeline::from_path(Path::new("pipeline.yaml"))?;
17//! let source = MySource::from_component_config(&pipeline.config().source)?;
18//! let sink = my_connector::from_component_config(pipeline.config().sink_config("default")?)?;
19//! let report = pipeline
20//!     .sink(sink)?
21//!     .chains(move |ctx| {
22//!         let chunk_cfg = ctx.chunk(); // bind before `with_metrics` moves `ctx.pipeline`
23//!         chain_owned::<Row, _>(deserializer.clone())
24//!             .with_metrics(ctx.pipeline, "main")
25//!             .sink(encoder.clone(), KeyHashRouter, chunk_cfg,
26//!                   ctx.queues, ctx.budget)
27//!             .build()
28//!     })
29//!     .run(source)?;
30//! report.log();
31//! std::process::exit(report.exit_code());
32//! ```
33//!
34//! # Desugaring
35//!
36//! Each builder step is a direct lift of the manual assembly it replaces
37//! (all of it public API):
38//!
39//! | Builder | Primitives |
40//! |---|---|
41//! | `from_config(config)` | [`telemetry::init`](crate::telemetry::init) → [`metrics::install`](crate::metrics::install)`(&`[`metrics_settings`](crate::pipeline::metrics_settings)`(&config))` → `tokio::runtime::Builder` (`io_threads` workers) → [`InflightBudget::new`](crate::backpressure::InflightBudget::new) |
42//! | `.sink(bundle)` | [`SinkBundle::into_parts`](crate::sink::SinkBundle::into_parts) → [`shard_queues`](crate::sink::shard_queues) → [`SinkShardMetrics::try_new`](crate::metrics::SinkShardMetrics::try_new) per shard → [`SinkPool::spawn`](crate::sink::SinkPool::spawn) → a boxed drain closure. It also resolves this sink's [`ChunkConfig`] from the YAML `chunk:` block / [`SinkOptions::with_chunk`]. This is the one builder step without a manual-assembly equivalent, since the config layer is what carries `chunk:`. |
43//! | `.chains(f)` | the factory handed to [`PipelineRuntime::new`], with queue/budget/name plumbing pre-threaded per call |
44//! | `.into_runtime(source)` / `.run(source)` | [`PipelineRuntime::new`]`(config, source, factory, `[`SinkRuntime`]`{..}, budget)` + [`PipelineRuntime::with_io_runtime`] |
45//!
46//! # Shutdown and drop ordering
47//!
48//! The sink only drains once every [`ShardQueues`] clone is gone. The
49//! builder discharges this structurally by never exposing the queues
50//! outside the chain factory. Each factory call receives a fresh clone in
51//! its [`ChainCtx`], which the chain's terminal stage consumes and drops
52//! with the driver threads, and the wrapper factory itself is dropped by
53//! the runtime before the drain. Do not smuggle `ctx.queues` into
54//! long-lived state outside the returned chain; a clone that outlives the
55//! drivers turns a graceful drain into a deadline-bounded abandon.
56
57use super::SinkRuntime;
58use super::runtime::{
59    PipelineRuntime, RuntimeOptions, StartError, install_or_reuse, metrics_settings,
60};
61use crate::backpressure::InflightBudget;
62use crate::config::{ConfigError, PipelineConfig};
63use crate::framing::FramingContract;
64use crate::metrics::{
65    ComponentLabels, Meter, MetricRole, MetricsHandle, SharedString, SinkShardMetrics,
66};
67use crate::ops::{ChunkConfig, RunnableChain, SinkCtx};
68use crate::pipeline::ExitReport;
69use crate::sink::{
70    DrainReport, ShardQueues, ShardWriter, SinkBundle, SinkDrainFn, SinkFailures, SinkPool,
71    SinkProbeFn, shard_queues,
72};
73use crate::source::Source;
74use crate::telemetry::{self, LogFormat};
75use std::path::Path;
76use std::sync::Arc;
77
78/// Error assembling a pipeline (cold path, before anything runs).
79#[derive(Debug, thiserror::Error)]
80#[non_exhaustive]
81pub enum BuildError {
82    /// The configuration failed to load or validate.
83    #[error(transparent)]
84    Config(#[from] ConfigError),
85    /// The metrics exporter failed to install.
86    #[error("metrics: {0}")]
87    Metrics(String),
88    /// The I/O runtime failed to build.
89    #[error("io runtime: {0}")]
90    Io(#[from] std::io::Error),
91    /// The sink bundle's topology or labels are unusable.
92    #[error("sink: {0}")]
93    Sink(String),
94    /// [`Pipeline::add_sink`] was called twice with the same name (a second
95    /// bare [`Pipeline::sink`] collides on the reserved `"default"` name).
96    #[error("a sink named {0:?} is already installed")]
97    DuplicateSinkName(String),
98    /// Another live handle set in this process already owns a metric series
99    /// this sink would publish, usually a second pipeline with the same
100    /// pipeline and sink name. Gauge series cannot be shared, so assembly
101    /// stops here rather than letting one of the two publish readings the
102    /// other overwrites. A pipeline rebuilt *sequentially* is fine: drop the
103    /// old one first.
104    #[error("{0}")]
105    DuplicateSeries(String),
106    /// [`Pipeline::into_runtime`]/[`Pipeline::run`] without a sink.
107    #[error("no sink installed (call Pipeline::sink or Pipeline::add_sink first)")]
108    MissingSink,
109    /// [`Pipeline::into_runtime`]/[`Pipeline::run`] without a chain factory.
110    #[error("no chain factory installed (call Pipeline::chains first)")]
111    MissingChains,
112    /// The builder was constructed inside an async runtime. It owns a
113    /// blocking tokio runtime (dropping or `block_on`-ing one inside async
114    /// context panics), so build pipelines from a plain thread, usually
115    /// `main`.
116    #[error(
117        "Pipeline::from_config must be called outside any async runtime \
118         (it owns a blocking tokio runtime)"
119    )]
120    AsyncContext,
121}
122
123/// Error from [`Pipeline::run`]: assembly or startup failure.
124#[derive(Debug, thiserror::Error)]
125#[non_exhaustive]
126pub enum PipelineError {
127    /// The pipeline could not be assembled.
128    #[error(transparent)]
129    Build(#[from] BuildError),
130    /// The assembled pipeline failed to start.
131    #[error(transparent)]
132    Start(#[from] StartError),
133}
134
135/// Per-thread wiring handed to the chain factory, carrying everything the
136/// terminal [`.sink(...)`](crate::ops::ChainBuilder) stage needs, so assemblies
137/// stop threading queues, budget, and the pipeline name by hand.
138///
139/// Passed by value, once per pipeline thread; move the fields into the
140/// chain being built. Not `Clone` (see the module docs on drop ordering).
141#[derive(Debug)]
142#[non_exhaustive]
143pub struct ChainCtx {
144    /// Zero-based pipeline thread index.
145    pub thread: usize,
146    /// This thread's clone of the shard-queue senders for the **first**
147    /// installed sink, the back-compat handle for single-sink pipelines
148    /// (`.sink(...)`). Multi-sink pipelines resolve each branch's queues by
149    /// name via [`sink`](Self::sink) instead.
150    pub queues: ShardQueues,
151    /// The shared in-flight byte budget.
152    pub budget: Arc<InflightBudget>,
153    /// The pipeline name
154    /// ([`ChainBuilder::with_metrics`](crate::ops::ChainBuilder::with_metrics)'s
155    /// first argument).
156    pub pipeline: String,
157    /// How the source frames its payloads
158    /// ([`Source::framing_contract`](crate::source::Source::framing_contract)).
159    /// Hand it to a deserializer builder (e.g. `JsonDeserializerBuilder::
160    /// for_source_framing`) so the deserializer's granularity is derived from
161    /// the source and a double-framing configuration is rejected.
162    pub source_framing: FramingContract,
163    /// The **first** installed sink's resolved terminal-stage chunking, the
164    /// back-compat handle for single-sink pipelines, mirroring [`queues`](Self::queues).
165    /// Pass it to the chain's [`.sink(...)`](crate::ops::ChainBuilder::sink)
166    /// terminal via [`chunk`](Self::chunk); split pipelines resolve each
167    /// branch's chunk by name through [`sink`](Self::sink) instead.
168    chunk: ChunkConfig,
169    /// This thread's clone of every installed sink's queues and resolved
170    /// chunking, keyed by the name passed to [`Pipeline::add_sink`]. Resolved
171    /// through [`sink`](Self::sink). The clones die with the driver.
172    named: Vec<(String, ShardQueues, ChunkConfig)>,
173}
174
175impl ChainCtx {
176    /// The named sink's handles (name, shard queues, shared in-flight budget)
177    /// for a split-terminal branch. Pass the result straight to
178    /// [`SplitBuilder::add`](crate::ops::SplitBuilder::add). The single-sink
179    /// `.sink()` sugar installs its sink under the name `"default"`.
180    ///
181    /// # Panics
182    ///
183    /// Panics if no sink was installed under `name`. The chain factory runs
184    /// once per thread, on the cold path, before any data flows.
185    #[must_use]
186    pub fn sink(&self, name: &str) -> SinkCtx {
187        let (_, queues, chunk) = self
188            .named
189            .iter()
190            .find(|(n, _, _)| n == name)
191            .unwrap_or_else(|| {
192                let known: Vec<&str> = self.named.iter().map(|(n, _, _)| n.as_str()).collect();
193                panic!("ChainCtx::sink: no sink named {name:?} (installed sinks: {known:?})")
194            });
195        SinkCtx::new(name.to_string(), queues.clone(), Arc::clone(&self.budget)).with_chunk(*chunk)
196    }
197
198    /// The **first** installed sink's resolved terminal-stage chunking, the
199    /// single-sink counterpart to [`queues`](Self::queues), fed straight to the
200    /// chain's [`.sink(...)`](crate::ops::ChainBuilder::sink) terminal:
201    ///
202    /// ```
203    /// # use spate_core::deser::{BytesPassthrough, Owned};
204    /// # use spate_core::error::SinkError;
205    /// # use spate_core::ops::chain;
206    /// # use spate_core::pipeline::ChainCtx;
207    /// # use spate_core::record::Record;
208    /// # use spate_core::sink::{KeyHashRouter, RowEncoder};
209    /// # #[derive(Clone)]
210    /// # struct RowBytes;
211    /// # impl RowEncoder<Owned<Vec<u8>>> for RowBytes {
212    /// #     fn encode<'buf>(
213    /// #         &mut self,
214    /// #         rec: &Record<Vec<u8>>,
215    /// #         buf: &mut bytes::BytesMut,
216    /// #     ) -> Result<(), SinkError> {
217    /// #         buf.extend_from_slice(&rec.payload);
218    /// #         Ok(())
219    /// #     }
220    /// # }
221    /// # fn wire(ctx: ChainCtx) {
222    /// # let encoder = RowBytes;
223    /// let chunk_cfg = ctx.chunk(); // bind before `with_metrics` moves `ctx.pipeline`
224    /// let orders = chain(BytesPassthrough)
225    ///     .with_metrics(ctx.pipeline, "orders")
226    ///     .sink(encoder, KeyHashRouter, chunk_cfg, ctx.queues, ctx.budget)
227    ///     .build();
228    /// # let _ = orders;
229    /// # }
230    /// ```
231    ///
232    /// It resolves the per-sink YAML `chunk:` block (or `SinkOptions::with_chunk`,
233    /// or the 64 KiB default) at assembly time. Split pipelines take each
234    /// branch's chunk from [`sink`](Self::sink) instead.
235    #[must_use]
236    pub fn chunk(&self) -> ChunkConfig {
237        self.chunk
238    }
239
240    /// A [`Meter`] for a pipeline author's own metrics, pre-labeled with the
241    /// pipeline name plus the `component` / `component_type` you name and
242    /// scoped to the `spate_custom_` namespace. Resolve handles from it **once
243    /// here** (the factory runs once per thread, before data flows) and move
244    /// them into the operator closures that touch them:
245    ///
246    /// ```no_run
247    /// # use spate_core::pipeline::ChainCtx;
248    /// # fn wire(ctx: ChainCtx) {
249    /// // Pass the LOCAL name — this registers `spate_custom_enrich_hits_total`.
250    /// let hits = ctx.meter("enrich", "map").counter("enrich_hits_total", &[]);
251    /// // ... move `hits` into a `.inspect(move |r| { hits.increment(1); })`
252    /// # let _ = hits;
253    /// # }
254    /// ```
255    ///
256    /// The resulting series carry `pipeline`/`component`/`component_type` like
257    /// every framework series and live under the `spate_` umbrella, so they join
258    /// cleanly in a query. You pass local names; the `Meter` adds the
259    /// `spate_custom_` prefix. [The metrics reference] carries the umbrella and
260    /// the label set in full.
261    ///
262    /// [the metrics reference]: https://spate.kainth.dev/docs/METRICS
263    #[must_use]
264    pub fn meter(
265        &self,
266        component: impl Into<SharedString>,
267        component_type: impl Into<SharedString>,
268    ) -> Meter {
269        Meter::new(self.pipeline.clone(), component, component_type)
270    }
271}
272
273/// Sink wiring knobs that live outside connector config.
274#[derive(Clone, Debug)]
275#[non_exhaustive]
276pub struct SinkOptions {
277    /// Per-shard chunk queue capacity, in chunks. The default suits most
278    /// pipelines. Queued chunks are charged to the in-flight byte budget, so
279    /// `shards × queue_capacity × chunk.target_bytes` has to fit under the
280    /// budget's low watermark *alongside* pending writes. When it does not, a
281    /// saturated pipeline sits permanently above the high watermark and the
282    /// pause controller duty-cycles at its minimum pause, collapsing
283    /// throughput with nothing otherwise wrong. [The backpressure page][bp]
284    /// carries the full sizing rule and a worked example.
285    ///
286    /// [bp]: https://spate.kainth.dev/docs/user-guide/concepts/backpressure
287    pub queue_capacity: usize,
288    /// Programmatic override for this sink's terminal-stage chunking. `None`
289    /// (the default) defers to the per-sink YAML `chunk:` block, or to
290    /// [`ChunkConfig::default`] if that is absent too. Setting it **and** a
291    /// YAML `chunk:` block on the same sink is a decl-once
292    /// [`ConfigError`](crate::config::ConfigError) at install; the knob is
293    /// declared in exactly one place. The resolved value reaches the chain
294    /// terminal via [`ChainCtx::chunk`] / [`ChainCtx::sink`].
295    pub chunk: Option<ChunkConfig>,
296}
297
298impl SinkOptions {
299    /// Override the per-shard queue capacity. (`SinkOptions` is
300    /// `#[non_exhaustive]`, so construct via `default()` + `with_*`.)
301    #[must_use]
302    pub fn with_queue_capacity(mut self, capacity: usize) -> Self {
303        self.queue_capacity = capacity;
304        self
305    }
306
307    /// Set this sink's chunking programmatically, instead of via the YAML
308    /// `chunk:` block. Providing both on the same sink is a load error (see
309    /// [`chunk`](Self::chunk)).
310    #[must_use]
311    pub fn with_chunk(mut self, chunk: ChunkConfig) -> Self {
312        self.chunk = Some(chunk);
313        self
314    }
315}
316
317impl Default for SinkOptions {
318    fn default() -> Self {
319        SinkOptions {
320            queue_capacity: 8,
321            chunk: None,
322        }
323    }
324}
325
326struct SinkAssembly {
327    queues: ShardQueues,
328    drain: SinkDrainFn,
329    probe: Option<SinkProbeFn>,
330    /// This sink's resolved terminal-stage chunking, threaded into every
331    /// per-thread [`ChainCtx`] (the default sink's becomes [`ChainCtx::chunk`];
332    /// named sinks reach it through [`ChainCtx::sink`]).
333    chunk: ChunkConfig,
334}
335
336type ChainFactoryFn = Box<dyn FnMut(ChainCtx) -> Box<dyn RunnableChain> + Send>;
337
338/// The pipeline builder. See the [module docs](crate::pipeline) for the full
339/// picture.
340///
341/// Non-generic, nameable, and storable; the source type enters only at the
342/// terminal [`into_runtime`](Self::into_runtime)/[`run`](Self::run) call.
343pub struct Pipeline {
344    config: PipelineConfig,
345    metrics: MetricsHandle,
346    io: tokio::runtime::Runtime,
347    budget: Arc<InflightBudget>,
348    sink_failures: SinkFailures,
349    sinks: Vec<(String, SinkAssembly)>,
350    chains: Option<ChainFactoryFn>,
351    options: RuntimeOptions,
352}
353
354impl std::fmt::Debug for Pipeline {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        let sink_names: Vec<&str> = self.sinks.iter().map(|(n, _)| n.as_str()).collect();
357        f.debug_struct("Pipeline")
358            .field("pipeline", &self.config.pipeline.name)
359            .field("sinks", &sink_names)
360            .field("chains", &self.chains.is_some())
361            .finish_non_exhaustive()
362    }
363}
364
365impl Pipeline {
366    /// Load configuration from a YAML file and initialize the process; see
367    /// [`from_config`](Self::from_config).
368    pub fn from_path(path: &Path) -> Result<Self, BuildError> {
369        Self::from_config(PipelineConfig::from_path(path)?)
370    }
371
372    /// Initialize the process from an already-loaded configuration:
373    ///
374    /// 1. **Telemetry** — [`telemetry::init`]`(Json, "info")`. Idempotent:
375    ///    to customize the format or filter, call [`telemetry::init`]
376    ///    yourself *first* (the binaries-init convention).
377    /// 2. **Metrics exporter** — installed from the config's `metrics`
378    ///    section before you can construct any handle, so every handle
379    ///    built while holding the `Pipeline` binds to the process's
380    ///    recorder, which always exists under `exporter: prometheus`.
381    ///    When a foreign recorder already owns the process, the pipeline
382    ///    continues against it with a warning.
383    /// 3. **The I/O runtime** — `pipeline.io_threads` workers, thread name
384    ///    `spate-io`. Connectors that need a handle before `run` (schema
385    ///    fetchers, async pre-flight validation) use
386    ///    [`io_handle`](Self::io_handle)/[`block_on`](Self::block_on).
387    ///
388    /// # Errors
389    ///
390    /// [`BuildError::AsyncContext`] when called from inside an async runtime.
391    /// Build pipelines from a plain thread, usually `main`.
392    pub fn from_config(config: PipelineConfig) -> Result<Self, BuildError> {
393        if tokio::runtime::Handle::try_current().is_ok() {
394            return Err(BuildError::AsyncContext);
395        }
396        if config.pipeline.io_threads == 0 {
397            return Err(BuildError::Config(ConfigError::Validation(
398                "pipeline.io_threads must be non-zero".into(),
399            )));
400        }
401        // The YAML loaders run the full `PipelineConfig::validate`; a
402        // programmatically built config skips it by design (minimal test
403        // fixtures). But `ComponentConfig::new` peels the reserved `chunk` key
404        // before the connector's `deny_unknown_fields` could reject it, so
405        // without this check a stray `chunk:` on a source/deserializer body
406        // would be silently swallowed here.
407        config.reject_stray_chunk().map_err(BuildError::Config)?;
408        telemetry::init(LogFormat::Json, "info");
409        let metrics = install_or_reuse(&metrics_settings(&config)).map_err(|e| match e {
410            StartError::Metrics(m) => BuildError::Metrics(m),
411            other => BuildError::Metrics(other.to_string()),
412        })?;
413        let io = tokio::runtime::Builder::new_multi_thread()
414            .worker_threads(config.pipeline.io_threads)
415            .thread_name("spate-io")
416            .enable_all()
417            .build()?;
418        Ok(Pipeline {
419            config,
420            metrics,
421            io,
422            budget: Arc::new(InflightBudget::new()),
423            sink_failures: SinkFailures::new(),
424            sinks: Vec::new(),
425            chains: None,
426            options: RuntimeOptions::default(),
427        })
428    }
429
430    /// The loaded configuration. Connector sections (`config().source`,
431    /// `.deserializer`, `.sink`) still belong to the caller's connector
432    /// factories.
433    #[must_use]
434    pub fn config(&self) -> &PipelineConfig {
435        &self.config
436    }
437
438    /// The installed exporter's handle (rendering, upkeep).
439    #[must_use]
440    pub fn metrics(&self) -> &MetricsHandle {
441        &self.metrics
442    }
443
444    /// The shared in-flight byte budget.
445    #[must_use]
446    pub fn budget(&self) -> &Arc<InflightBudget> {
447        &self.budget
448    }
449
450    /// A handle to the I/O runtime, for connector edge work that must
451    /// start before the chain exists (schema-registry fetchers, ...).
452    /// Valid until `run` returns; once the pipeline has started, that
453    /// includes the source's `Drop`.
454    #[must_use]
455    pub fn io_handle(&self) -> tokio::runtime::Handle {
456        self.io.handle().clone()
457    }
458
459    /// Run a future on the I/O runtime, blocking this thread. Use it for
460    /// async pre-flight steps such as schema validation.
461    pub fn block_on<F: Future>(&self, future: F) -> F::Output {
462        self.io.block_on(future)
463    }
464
465    /// Install the single sink under the reserved name `"default"` with
466    /// default [`SinkOptions`], the ergonomic path for single-sink
467    /// pipelines. Sugar for [`add_sink`](Self::add_sink)`("default", bundle)`.
468    pub fn sink<B: SinkBundle>(self, bundle: B) -> Result<Self, BuildError> {
469        self.add_sink_with("default", bundle, SinkOptions::default())
470    }
471
472    /// [`sink`](Self::sink) with explicit [`SinkOptions`]. Sugar for
473    /// [`add_sink_with`](Self::add_sink_with)`("default", bundle, options)`.
474    pub fn sink_with<B: SinkBundle>(
475        self,
476        bundle: B,
477        options: SinkOptions,
478    ) -> Result<Self, BuildError> {
479        self.add_sink_with("default", bundle, options)
480    }
481
482    /// Install a named sink with default [`SinkOptions`]; see
483    /// [`add_sink_with`](Self::add_sink_with). Call once per destination
484    /// table/stream; the chain's [`split`](crate::ops::ChainBuilder) terminal
485    /// resolves each branch's queues by this name via
486    /// [`ChainCtx::sink`](ChainCtx::sink).
487    pub fn add_sink<B: SinkBundle>(
488        self,
489        name: impl Into<String>,
490        bundle: B,
491    ) -> Result<Self, BuildError> {
492        self.add_sink_with(name, bundle, SinkOptions::default())
493    }
494
495    /// Install a named sink. This builds the per-shard chunk queues, registers
496    /// the per-shard metrics (E2E basis from the config; the sink `name`
497    /// becomes the `component` label, so each sink's `spate_sink_*` series is
498    /// distinct), spawns the [`SinkPool`] workers on the I/O runtime, and
499    /// wires the drain and readiness probe. The named sinks share the one
500    /// pipeline
501    /// [`InflightBudget`] and one backpressure controller (a stall on any sink
502    /// pauses the shared source).
503    ///
504    /// # Errors
505    ///
506    /// [`BuildError::DuplicateSinkName`] when `name` is already installed;
507    /// [`BuildError::Sink`] for an empty or reserved name (`"sink"` is the
508    /// default sink's metric label), an empty or ragged topology, label
509    /// shapes that do not match it, or a zero queue capacity.
510    pub fn add_sink_with<B: SinkBundle>(
511        mut self,
512        name: impl Into<String>,
513        bundle: B,
514        options: SinkOptions,
515    ) -> Result<Self, BuildError> {
516        let sink_name = name.into();
517        if self.sinks.iter().any(|(n, _)| n == &sink_name) {
518            return Err(BuildError::DuplicateSinkName(sink_name));
519        }
520        if sink_name.is_empty() {
521            return Err(BuildError::Sink("sink name must be non-empty".into()));
522        }
523        // "default" maps to the historical component="sink" metric label, so
524        // a sink literally named "sink" would silently merge its spate_sink_*
525        // series with the default's. Reject it up front.
526        if sink_name == "sink" {
527            return Err(BuildError::Sink(
528                "the sink name \"sink\" is reserved (it is the default sink's \
529                 metric label); pick another name"
530                    .into(),
531            ));
532        }
533        if options.queue_capacity == 0 {
534            return Err(BuildError::Sink("queue_capacity must be non-zero".into()));
535        }
536        // Resolve the terminal-stage chunking BEFORE any I/O worker spawns, so
537        // a config error can't leak a running SinkPool. Decl-once: the per-sink
538        // YAML `chunk:` block and `SinkOptions::with_chunk` are mutually
539        // exclusive.
540        let yaml_chunk = match self.config.sink_config(&sink_name) {
541            // Prefix the sink name onto the error so a multi-sink pipeline
542            // says *which* sink is misconfigured (the dotted Component path
543            // alone is `sink.<type>.…`, identical for same-typed sinks).
544            Ok(cc) => cc.resolved_chunk().map_err(|e| {
545                BuildError::Config(match e {
546                    ConfigError::Validation(m) => {
547                        ConfigError::Validation(format!("sink {sink_name:?}: {m}"))
548                    }
549                    ConfigError::Component { context, message } => ConfigError::Component {
550                        context: format!("sink {sink_name:?}: {context}"),
551                        message,
552                    },
553                    other => other,
554                })
555            })?,
556            // No YAML section under this name. The sink is configured purely
557            // in code (capture sinks in tests, named programmatic sinks beside
558            // a placeholder `sink:`). A declared `chunk:` block that nothing
559            // ever installs is warned about at `into_runtime`, where the
560            // installed-name set is complete.
561            Err(_) => None,
562        };
563        let chunk = match (yaml_chunk, options.chunk) {
564            (Some(_), Some(_)) => {
565                return Err(BuildError::Config(ConfigError::Validation(format!(
566                    "sink {sink_name:?}: set the chunk config in exactly one place — the \
567                     YAML `chunk:` block or `SinkOptions::with_chunk`, not both"
568                ))));
569            }
570            (Some(c), None) | (None, Some(c)) => c,
571            (None, None) => ChunkConfig::default(),
572        };
573        // The YAML path already checked `> 0` in `ChunkSection::resolve`; the
574        // programmatic `with_chunk` path skips that, so enforce parity here
575        // (naming the sink). There is no upper bound. The in-flight budget can
576        // be smaller than one chunk on a throttled config; that is heavy
577        // backpressure rather than a misconfiguration. `target_bytes` is a
578        // per-shard pre-allocation; sizing it is the operator's call (see the
579        // tuning docs).
580        if chunk.target_bytes == 0 {
581            return Err(BuildError::Config(ConfigError::Validation(format!(
582                "sink {sink_name:?}: chunk.target_bytes must be greater than zero"
583            ))));
584        }
585        let parts = bundle.into_parts();
586        let num_shards = parts.shard_endpoints.len();
587        if num_shards == 0 {
588            return Err(BuildError::Sink("sink topology has no shards".into()));
589        }
590        if let Some(shard) = parts.shard_endpoints.iter().position(Vec::is_empty) {
591            return Err(BuildError::Sink(format!("shard {shard} has no replicas")));
592        }
593        let replica_labels = parts.effective_replica_labels();
594        let label_shape: Vec<usize> = replica_labels.iter().map(Vec::len).collect();
595        let endpoint_shape: Vec<usize> = parts.shard_endpoints.iter().map(Vec::len).collect();
596        if label_shape != endpoint_shape {
597            return Err(BuildError::Sink(format!(
598                "replica_labels shape {label_shape:?} does not match the \
599                 endpoint topology {endpoint_shape:?}"
600            )));
601        }
602
603        let pipeline_name = self.config.pipeline.name.clone();
604        // The single-sink default keeps the historical `component="sink"`
605        // label; named sinks use their name so their series never collide.
606        let component = if sink_name == "default" {
607            "sink".to_string()
608        } else {
609            sink_name.clone()
610        };
611        let (mut queues, receivers) = shard_queues(num_shards, options.queue_capacity);
612        // The sink's custom-metrics scope (`spate_<component_type>_sink_*`),
613        // handed to the writer before it is shared across shard workers.
614        let sink_meter = Meter::for_component(
615            &parts.component_type,
616            MetricRole::Sink,
617            pipeline_name.clone(),
618            component.clone(),
619        );
620        let sink_labels = ComponentLabels::new(
621            pipeline_name.clone(),
622            component,
623            parts.component_type.clone(),
624        );
625        // Pre-register the queue-edge handles before `queues` is cloned into
626        // any terminal, so every producer shares the same `spate_queue_*` series.
627        queues
628            .attach_metrics(&sink_labels)
629            .map_err(|e| BuildError::DuplicateSeries(e.to_string()))?;
630        let e2e_basis = metrics_settings(&self.config).e2e_basis;
631        // A shard's gauges are edge-triggered, so two live handle sets on one
632        // series leave a stale reading standing rather than a double count. On
633        // the assembly path that is a wiring mistake we can still refuse,
634        // before any data flows.
635        let shard_metrics: Vec<SinkShardMetrics> = replica_labels
636            .iter()
637            .enumerate()
638            .map(|(shard, replicas)| {
639                SinkShardMetrics::try_new(
640                    &sink_labels,
641                    u32::try_from(shard).unwrap_or(u32::MAX),
642                    replicas,
643                    e2e_basis,
644                )
645                .map_err(|e| BuildError::DuplicateSeries(e.to_string()))
646            })
647            .collect::<Result<_, _>>()?;
648        let mut writer = parts.writer;
649        writer.attach_metrics(sink_meter);
650        let pool = SinkPool::spawn(
651            Arc::new(writer),
652            parts.shard_endpoints,
653            receivers,
654            parts.pool,
655            Arc::clone(&self.budget),
656            shard_metrics,
657            &pipeline_name,
658            &sink_name,
659            self.sink_failures.clone(),
660            self.io.handle(),
661        );
662        self.sinks.push((
663            sink_name,
664            SinkAssembly {
665                queues,
666                drain: Box::new(move |deadline| {
667                    Box::pin(async move { pool.drain(deadline).await })
668                }),
669                probe: parts.probe,
670                chunk,
671            },
672        ));
673        Ok(self)
674    }
675
676    /// Install the chain factory, called once per pipeline thread with
677    /// that thread's [`ChainCtx`]. Composition inside the closure is fully
678    /// monomorphized ([`chain_owned`](crate::ops::chain_owned) and
679    /// friends); the returned `Box<dyn RunnableChain>` is the single
680    /// per-batch erasure boundary.
681    #[must_use]
682    pub fn chains<F>(mut self, factory: F) -> Self
683    where
684        F: FnMut(ChainCtx) -> Box<dyn RunnableChain> + Send + 'static,
685    {
686        self.chains = Some(Box::new(factory));
687        self
688    }
689
690    /// Override the runtime options (signal handling, loop timings).
691    #[must_use]
692    pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
693        self.options = options;
694        self
695    }
696
697    /// Finish assembly into a [`PipelineRuntime`], for callers that need
698    /// [`shutdown_handle`](PipelineRuntime::shutdown_handle) before a
699    /// spawned `run` (tests, embedded pipelines). The I/O runtime moves
700    /// into it and is shut down when `run` returns.
701    ///
702    /// # Errors
703    ///
704    /// [`BuildError::MissingSink`] / [`BuildError::MissingChains`] when a
705    /// step was skipped.
706    pub fn into_runtime<S: Source + 'static>(
707        mut self,
708        source: S,
709    ) -> Result<PipelineRuntime<S>, BuildError> {
710        if self.sinks.is_empty() {
711            return Err(BuildError::MissingSink);
712        }
713        let mut factory = self.chains.take().ok_or(BuildError::MissingChains)?;
714
715        // Decompose the installed sinks into: the per-thread named queue set
716        // (cloned into each ChainCtx), the introspection queues (the
717        // backpressure resume gate spans every sink), and the drain/probe
718        // hooks (composed into one of each for the runtime).
719        let mut intro_queues = Vec::with_capacity(self.sinks.len());
720        let mut drains = Vec::with_capacity(self.sinks.len());
721        let mut probes = Vec::new();
722        let mut named = Vec::with_capacity(self.sinks.len());
723        for (sink_name, assembly) in std::mem::take(&mut self.sinks) {
724            intro_queues.push(assembly.queues.clone());
725            named.push((sink_name, assembly.queues, assembly.chunk));
726            drains.push(assembly.drain);
727            if let Some(probe) = assembly.probe {
728                probes.push(probe);
729            }
730        }
731        // A declared sink section whose name nothing installed can carry a
732        // `chunk:` block that no install-time resolution will ever read, the
733        // one mismatch invisible to `add_sink_with`. (A *malformed* block is
734        // already rejected by `PipelineConfig::validate` on the YAML loaders.)
735        for config_name in self.config.sink_names() {
736            if !named.iter().any(|(n, _, _)| *n == config_name)
737                && self
738                    .config
739                    .sink_config(&config_name)
740                    .is_ok_and(crate::config::ComponentConfig::has_chunk)
741            {
742                tracing::warn!(
743                    sink = %config_name,
744                    "config declares a `chunk:` block for this sink, but no sink was \
745                     installed under that name — the block is ignored (name mismatch \
746                     between the config and add_sink)"
747                );
748            }
749        }
750        let default_queues = named[0].1.clone();
751        let default_chunk = named[0].2;
752        let budget = Arc::clone(&self.budget);
753        let name = self.config.pipeline.name.clone();
754        // Read the source's framing contract once, before it moves into the
755        // runtime, and hand it to every per-thread ChainCtx.
756        let source_framing = source.framing_contract();
757        // This wrapper is the factory the runtime drops before the sink
758        // drain; the queue clones it captures die there.
759        let chains = move |thread: usize| {
760            factory(ChainCtx {
761                thread,
762                queues: default_queues.clone(),
763                budget: Arc::clone(&budget),
764                pipeline: name.clone(),
765                source_framing,
766                chunk: default_chunk,
767                named: named.clone(),
768            })
769        };
770        Ok(PipelineRuntime::new(
771            self.config,
772            source,
773            chains,
774            SinkRuntime {
775                queues: intro_queues,
776                drain: combine_drains(drains),
777                probe: combine_probes(probes),
778                failures: self.sink_failures,
779            },
780            self.budget,
781        )
782        .with_options(self.options)
783        .with_io_runtime(self.io))
784    }
785
786    /// [`into_runtime`](Self::into_runtime) + [`PipelineRuntime::run`]:
787    /// run the pipeline to completion, blocking until a shutdown signal
788    /// drains it or a fatal error stops it.
789    pub fn run<S: Source + 'static>(self, source: S) -> Result<ExitReport, PipelineError> {
790        Ok(self.into_runtime(source)?.run()?)
791    }
792}
793
794/// Compose per-sink drain hooks into one. Every sink drains concurrently under
795/// the shared deadline and their reports are summed, so a multi-sink drain
796/// respects one wall-clock budget instead of N sequential ones.
797fn combine_drains(drains: Vec<SinkDrainFn>) -> SinkDrainFn {
798    Box::new(move |deadline| {
799        Box::pin(async move {
800            let mut set = tokio::task::JoinSet::new();
801            for drain in drains {
802                set.spawn(drain(deadline));
803            }
804            let mut total = DrainReport::default();
805            while let Some(res) = set.join_next().await {
806                match res {
807                    Ok(report) => {
808                        total.flushed += report.flushed;
809                        total.abandoned += report.abandoned;
810                    }
811                    // The panicked sink's counts are unknowable; its parked
812                    // acks still fail on drop, so at-least-once holds. The
813                    // report is incomplete and says so.
814                    Err(e) => tracing::error!(
815                        error = %e,
816                        "a sink drain task panicked; its counts are missing \
817                         from the drain report"
818                    ),
819                }
820            }
821            total
822        })
823    })
824}
825
826/// Compose per-sink readiness probes into one. Every sink is probed and the
827/// result is connected only when all succeed (readiness is not a hot path, so
828/// the sequential short-circuit is fine).
829fn combine_probes(probes: Vec<SinkProbeFn>) -> Option<SinkProbeFn> {
830    if probes.is_empty() {
831        return None;
832    }
833    let probes = Arc::new(probes);
834    Some(Box::new(move || {
835        let probes = Arc::clone(&probes);
836        Box::pin(async move {
837            for probe in probes.iter() {
838                probe().await?;
839            }
840            Ok(())
841        })
842    }))
843}
844
845#[cfg(all(test, not(loom)))]
846mod tests {
847    use super::*;
848    use crate::config::ComponentConfig;
849    use crate::error::SinkError;
850    use crate::pipeline::ExitState;
851    use crate::pipeline::fakes::{
852        ChainMode, ChainShared, FakeChain, FakeSource, LaneSpec, Script, SourceLog, batches,
853        test_config, test_options, wait_for,
854    };
855    use crate::record::PartitionId;
856    use crate::sink::{SealedBatch, SinkParts, SinkPoolConfig};
857    use crate::source::LaneId;
858    use std::sync::Mutex;
859    use std::time::Duration;
860
861    struct NullWriter;
862    impl crate::sink::ShardWriter for NullWriter {
863        type Endpoint = ();
864        async fn write_batch(&self, (): &(), _batch: &SealedBatch) -> Result<(), SinkError> {
865            Ok(())
866        }
867    }
868
869    fn null_sink(shards: usize) -> SinkParts<NullWriter> {
870        SinkParts::new(
871            NullWriter,
872            (0..shards).map(|_| vec![()]).collect(),
873            SinkPoolConfig::default(),
874        )
875        .with_component_type("null")
876    }
877
878    fn fake_chain(
879        shared: &Arc<ChainShared>,
880        log: &Arc<Mutex<SourceLog>>,
881    ) -> Box<dyn RunnableChain> {
882        Box::new(FakeChain {
883            shared: Arc::clone(shared),
884            log: Arc::clone(log),
885            mode: ChainMode::Ok,
886            batches_seen: 0,
887        })
888    }
889
890    #[test]
891    fn missing_sink_then_missing_chains_error() {
892        let (source, _shared, _script) = FakeSource::new();
893        let p = Pipeline::from_config(test_config(1)).expect("builder");
894        assert!(matches!(
895            p.into_runtime(source).err(),
896            Some(BuildError::MissingSink)
897        ));
898
899        let (source, _shared, _script) = FakeSource::new();
900        let p = Pipeline::from_config(test_config(1))
901            .expect("builder")
902            .sink(null_sink(1))
903            .expect("sink");
904        assert!(matches!(
905            p.into_runtime(source).err(),
906            Some(BuildError::MissingChains)
907        ));
908    }
909
910    #[test]
911    fn duplicate_sink_name_errors() {
912        // A second bare `.sink()` collides on the reserved "default" name.
913        let p = Pipeline::from_config(test_config(1))
914            .expect("builder")
915            .sink(null_sink(1))
916            .expect("first sink");
917        assert!(matches!(
918            p.sink(null_sink(1)).err(),
919            Some(BuildError::DuplicateSinkName(name)) if name == "default"
920        ));
921
922        // The same explicit name twice also collides.
923        let p = Pipeline::from_config(test_config(1))
924            .expect("builder")
925            .add_sink("a", null_sink(1))
926            .expect("first");
927        assert!(matches!(
928            p.add_sink("a", null_sink(1)).err(),
929            Some(BuildError::DuplicateSinkName(name)) if name == "a"
930        ));
931    }
932
933    #[test]
934    fn distinct_named_sinks_install() {
935        Pipeline::from_config(test_config(1))
936            .expect("builder")
937            .add_sink("a", null_sink(1))
938            .expect("first")
939            .add_sink("b", null_sink(2))
940            .expect("second install with a distinct name");
941    }
942
943    /// Two *live* pipelines with the same pipeline and sink name would resolve
944    /// the same `spate_sink_*` and `spate_queue_*` gauge series. The second's
945    /// `add_sink` must refuse; those gauges cannot be shared, and letting both
946    /// through leaves each overwriting the other's readings.
947    ///
948    /// A pipeline rebuilt *sequentially* (the supported way to replace one in
949    /// a process) is fine. The first's claim frees when it is dropped, so the
950    /// rebuild re-owns the series. Only overlap collides.
951    #[test]
952    fn two_live_pipelines_on_one_name_collide_but_sequential_reuse_is_fine() {
953        let named = || {
954            let mut cfg = test_config(1);
955            cfg.pipeline.name = "shared-name".into();
956            cfg
957        };
958
959        // First pipeline, held alive across the second's build.
960        let first = Pipeline::from_config(named())
961            .expect("builder")
962            .sink(null_sink(2))
963            .expect("first pipeline claims the sink series");
964
965        let collision = Pipeline::from_config(named())
966            .expect("builder")
967            .sink(null_sink(2));
968        assert!(
969            matches!(collision.err(), Some(BuildError::DuplicateSeries(msg)) if msg.contains("spate_")),
970            "a second live pipeline on the same name must fail on the series claim"
971        );
972
973        // Drop the first, freeing its claims, and rebuild: the series is
974        // available again.
975        drop(first);
976        Pipeline::from_config(named())
977            .expect("builder")
978            .sink(null_sink(2))
979            .expect("sequential rebuild re-owns the freed series");
980    }
981
982    #[test]
983    fn reserved_and_empty_sink_names_error() {
984        // "sink" is the default sink's metric label; installing a sink under
985        // that name would silently merge the two series.
986        let p = Pipeline::from_config(test_config(1)).expect("builder");
987        assert!(matches!(
988            p.add_sink("sink", null_sink(1)).err(),
989            Some(BuildError::Sink(msg)) if msg.contains("reserved")
990        ));
991
992        let p = Pipeline::from_config(test_config(1)).expect("builder");
993        assert!(matches!(
994            p.add_sink("", null_sink(1)).err(),
995            Some(BuildError::Sink(msg)) if msg.contains("non-empty")
996        ));
997    }
998
999    #[test]
1000    fn bad_topologies_error_instead_of_panicking() {
1001        let p = Pipeline::from_config(test_config(1)).expect("builder");
1002        let empty = SinkParts::new(NullWriter, Vec::new(), SinkPoolConfig::default());
1003        assert!(matches!(p.sink(empty).err(), Some(BuildError::Sink(_))));
1004
1005        let p = Pipeline::from_config(test_config(1)).expect("builder");
1006        let ragged = SinkParts::new(
1007            NullWriter,
1008            vec![vec![()], vec![]],
1009            SinkPoolConfig::default(),
1010        );
1011        assert!(matches!(p.sink(ragged).err(), Some(BuildError::Sink(_))));
1012
1013        let p = Pipeline::from_config(test_config(1)).expect("builder");
1014        let bad_labels = SinkParts::new(NullWriter, vec![vec![()]], SinkPoolConfig::default())
1015            .with_replica_labels(vec![vec!["a".into(), "b".into()]]);
1016        assert!(matches!(
1017            p.sink(bad_labels).err(),
1018            Some(BuildError::Sink(_))
1019        ));
1020
1021        let p = Pipeline::from_config(test_config(1)).expect("builder");
1022        assert!(matches!(
1023            p.sink_with(null_sink(1), SinkOptions::default().with_queue_capacity(0))
1024                .err(),
1025            Some(BuildError::Sink(_))
1026        ));
1027    }
1028
1029    #[tokio::test]
1030    async fn from_config_inside_async_context_errors() {
1031        assert!(matches!(
1032            Pipeline::from_config(test_config(1)).err(),
1033            Some(BuildError::AsyncContext)
1034        ));
1035    }
1036
1037    /// The chain factory sees every thread index exactly once, with the
1038    /// pipeline name from the config, and the assembled pipeline runs to a
1039    /// clean `Completed` through the real `SinkPool`. This guards `ChainCtx`
1040    /// coverage and end-to-end assembly, not drop ordering. Drain containment
1041    /// (`sink/worker.rs`) turns a leaked `ShardQueues` clone into a bounded
1042    /// abandon rather than an unbounded hang, so completion here does not
1043    /// imply clean drop ordering. The drop-ordering and at-least-once contract
1044    /// is covered where it can still fail observably: the whole-assembly test
1045    /// in `spate-test`'s `tests/bundle.rs`, which routes real data through
1046    /// `ctx.queues` and asserts the watermark only advances past the last
1047    /// record after a durable write.
1048    #[test]
1049    fn chain_ctx_covers_every_thread_and_run_completes() {
1050        let (source, shared, script) = FakeSource::new();
1051        script
1052            .lock()
1053            .unwrap()
1054            .push_back(Script::Assign(vec![LaneSpec {
1055                id: LaneId(0),
1056                partition: PartitionId(0),
1057                batches: batches(&[0..10, 10..20]),
1058            }]));
1059        let chain_shared = Arc::new(ChainShared::default());
1060        let seen_threads: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1061
1062        let cs = Arc::clone(&chain_shared);
1063        let log = Arc::clone(&shared);
1064        let seen = Arc::clone(&seen_threads);
1065        let config = test_config(2);
1066        let pipeline_name = config.pipeline.name.clone();
1067        let runtime = Pipeline::from_config(config)
1068            .expect("builder")
1069            .sink(null_sink(1))
1070            .expect("sink")
1071            .chains(move |ctx| {
1072                assert_eq!(ctx.pipeline, pipeline_name);
1073                // The source's framing contract threads into every ChainCtx;
1074                // FakeSource overrides it to the non-default PerRecord.
1075                assert_eq!(ctx.source_framing, FramingContract::PerRecord);
1076                seen.lock().unwrap().push(ctx.thread);
1077                fake_chain(&cs, &log)
1078            })
1079            .runtime_options(test_options())
1080            .into_runtime(source)
1081            .expect("into_runtime");
1082
1083        let shutdown = runtime.shutdown_handle();
1084        let join = std::thread::spawn(move || runtime.run());
1085        wait_for("payloads consumed", Duration::from_secs(5), || {
1086            chain_shared
1087                .consumed
1088                .load(std::sync::atomic::Ordering::Relaxed)
1089                == 20
1090        });
1091        shutdown.trigger();
1092        let report = join.join().unwrap().unwrap();
1093        assert_eq!(report.state, ExitState::Completed);
1094
1095        let mut threads = seen_threads.lock().unwrap().clone();
1096        threads.sort_unstable();
1097        assert_eq!(threads, vec![0, 1], "one ChainCtx per pipeline thread");
1098    }
1099
1100    /// A `chunk:` body peeled onto a `ComponentConfig`, the shape the framework
1101    /// resolves at install without the connector ever seeing the key.
1102    fn chunk_body(yaml: &str) -> ComponentConfig {
1103        ComponentConfig::new("fake", serde_yaml::from_str(yaml).expect("chunk yaml"))
1104    }
1105
1106    #[test]
1107    fn yaml_and_programmatic_chunk_on_one_sink_collide() {
1108        // Decl-once: setting the YAML `chunk:` block AND `SinkOptions::with_chunk`
1109        // on the same sink is a config error, not a silent override.
1110        let mut config = test_config(1);
1111        config.sink = Some(chunk_body("chunk: { target_bytes: 128KiB }"));
1112        let err = Pipeline::from_config(config)
1113            .expect("builder")
1114            .sink_with(
1115                null_sink(1),
1116                SinkOptions::default().with_chunk(ChunkConfig::default()),
1117            )
1118            .err();
1119        assert!(
1120            matches!(&err, Some(BuildError::Config(ConfigError::Validation(m))) if m.contains("exactly one place")),
1121            "{err:?}"
1122        );
1123    }
1124
1125    #[test]
1126    fn programmatic_zero_target_bytes_is_rejected_at_install() {
1127        let err = Pipeline::from_config(test_config(1))
1128            .expect("builder")
1129            .sink_with(
1130                null_sink(1),
1131                SinkOptions::default().with_chunk(ChunkConfig {
1132                    target_bytes: 0,
1133                    encode_policy: crate::error::ErrorPolicy::Skip,
1134                }),
1135            )
1136            .err();
1137        assert!(
1138            matches!(&err, Some(BuildError::Config(ConfigError::Validation(m))) if m.contains("chunk.target_bytes")),
1139            "{err:?}"
1140        );
1141    }
1142
1143    #[test]
1144    fn each_split_branch_resolves_its_own_chunk() {
1145        let (source, shared, script) = FakeSource::new();
1146        script
1147            .lock()
1148            .unwrap()
1149            .push_back(Script::Assign(vec![LaneSpec {
1150                id: LaneId(0),
1151                partition: PartitionId(0),
1152                batches: batches(&[0..1, 1..2]),
1153            }]));
1154
1155        let mut config = test_config(1);
1156        config.sink = None;
1157        let mut sinks = std::collections::BTreeMap::new();
1158        sinks.insert(
1159            "a".to_string(),
1160            chunk_body("chunk: { target_bytes: 128KiB }"),
1161        );
1162        sinks.insert(
1163            "b".to_string(),
1164            chunk_body("chunk: { target_bytes: 512KiB }"),
1165        );
1166        config.sinks = Some(sinks);
1167
1168        let captured: Arc<Mutex<Vec<(usize, usize)>>> = Arc::new(Mutex::new(Vec::new()));
1169        let cap = Arc::clone(&captured);
1170        let chain_shared = Arc::new(ChainShared::default());
1171        let cs = Arc::clone(&chain_shared);
1172        let log = Arc::clone(&shared);
1173        let runtime = Pipeline::from_config(config)
1174            .expect("builder")
1175            .add_sink("a", null_sink(1))
1176            .expect("sink a")
1177            .add_sink("b", null_sink(1))
1178            .expect("sink b")
1179            .chains(move |ctx| {
1180                cap.lock().unwrap().push((
1181                    ctx.sink("a").chunk.target_bytes,
1182                    ctx.sink("b").chunk.target_bytes,
1183                ));
1184                fake_chain(&cs, &log)
1185            })
1186            .runtime_options(test_options())
1187            .into_runtime(source)
1188            .expect("into_runtime");
1189
1190        let shutdown = runtime.shutdown_handle();
1191        let join = std::thread::spawn(move || runtime.run());
1192        wait_for("chain factory ran", Duration::from_secs(5), || {
1193            !captured.lock().unwrap().is_empty()
1194        });
1195        shutdown.trigger();
1196        let _ = join.join().unwrap();
1197
1198        assert_eq!(
1199            captured.lock().unwrap()[0],
1200            (128 * 1024, 512 * 1024),
1201            "each branch resolves its own per-sink chunk"
1202        );
1203    }
1204
1205    /// Drive a capture of `ctx.chunk()` through a full assembly, shared by
1206    /// the YAML-block and `with_chunk` propagation tests below.
1207    fn captured_default_chunk(
1208        build: impl FnOnce(Pipeline) -> Result<Pipeline, BuildError>,
1209        config: PipelineConfig,
1210    ) -> usize {
1211        let (source, shared, script) = FakeSource::new();
1212        script
1213            .lock()
1214            .unwrap()
1215            .push_back(Script::Assign(vec![LaneSpec {
1216                id: LaneId(0),
1217                partition: PartitionId(0),
1218                batches: batches(&[0..1, 1..2]),
1219            }]));
1220        let captured: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1221        let cap = Arc::clone(&captured);
1222        let chain_shared = Arc::new(ChainShared::default());
1223        let cs = Arc::clone(&chain_shared);
1224        let log = Arc::clone(&shared);
1225        let runtime = build(Pipeline::from_config(config).expect("builder"))
1226            .expect("sink install")
1227            .chains(move |ctx| {
1228                cap.lock().unwrap().push(ctx.chunk().target_bytes);
1229                fake_chain(&cs, &log)
1230            })
1231            .runtime_options(test_options())
1232            .into_runtime(source)
1233            .expect("into_runtime");
1234
1235        let shutdown = runtime.shutdown_handle();
1236        let join = std::thread::spawn(move || runtime.run());
1237        wait_for("chain factory ran", Duration::from_secs(5), || {
1238            !captured.lock().unwrap().is_empty()
1239        });
1240        shutdown.trigger();
1241        let _ = join.join().unwrap();
1242        let captured = captured.lock().unwrap();
1243        captured[0]
1244    }
1245
1246    #[test]
1247    fn default_sink_yaml_chunk_reaches_ctx_chunk() {
1248        // The headline single-sink path: `sink.<type>.chunk` must arrive at
1249        // the chain factory via `ctx.chunk()`, not silently stay the default.
1250        let mut config = test_config(1);
1251        config.sink = Some(chunk_body("chunk: { target_bytes: 128KiB }"));
1252        let bytes = captured_default_chunk(|p| p.sink(null_sink(1)), config);
1253        assert_eq!(bytes, 128 * 1024, "YAML chunk block reaches ctx.chunk()");
1254    }
1255
1256    #[test]
1257    fn with_chunk_reaches_ctx_chunk() {
1258        let bytes = captured_default_chunk(
1259            |p| {
1260                p.sink_with(
1261                    null_sink(1),
1262                    SinkOptions::default().with_chunk(ChunkConfig {
1263                        target_bytes: 96 * 1024,
1264                        encode_policy: crate::error::ErrorPolicy::Skip,
1265                    }),
1266                )
1267            },
1268            test_config(1),
1269        );
1270        assert_eq!(bytes, 96 * 1024, "with_chunk reaches ctx.chunk()");
1271    }
1272
1273    #[test]
1274    fn stray_chunk_on_a_source_body_is_rejected_by_from_config() {
1275        // `ComponentConfig::new` peels the reserved key before the connector's
1276        // `deny_unknown_fields` could reject it, so `from_config` must reject
1277        // it itself. A stray `chunk:` on a source is an error, not a silent
1278        // no-op, even on the programmatic path that skips `validate`.
1279        let mut config = test_config(1);
1280        config.source = ComponentConfig::new(
1281            "fake",
1282            serde_yaml::from_str("chunk: { target_bytes: 64KiB }").expect("yaml"),
1283        );
1284        let err = Pipeline::from_config(config).err();
1285        assert!(
1286            matches!(&err, Some(BuildError::Config(ConfigError::Validation(m))) if m.contains("source")),
1287            "{err:?}"
1288        );
1289    }
1290
1291    /// Regression: the runtime must hand the source the framework's
1292    /// source-stage handles at `open`.
1293    ///
1294    /// `SourceMetrics::set_partition_lag` has exactly one possible caller, the
1295    /// source, because only the client can see the log end. When those handles
1296    /// are reachable only through a connector-side builder that nothing calls,
1297    /// `spate_source_lag_records` renders a permanent `0` on every Kafka
1298    /// pipeline. A maximally backlogged consumer reports no lag, and any alert
1299    /// or autoscaler keyed on it reads as caught up. Nothing fails; the series
1300    /// is always zero. This asserts the seam is connected. The Kafka crate's
1301    /// `a_backlogged_consumer_publishes_its_lag` covers the value arriving,
1302    /// which this test does not.
1303    #[test]
1304    fn source_open_receives_the_stage_metrics() {
1305        let (source, shared, script) = FakeSource::new();
1306        script
1307            .lock()
1308            .unwrap()
1309            .push_back(Script::Assign(vec![LaneSpec {
1310                id: LaneId(0),
1311                partition: PartitionId(0),
1312                batches: batches(&[0..2, 2..4]),
1313            }]));
1314        let chain_shared = Arc::new(ChainShared::default());
1315        let cs = Arc::clone(&chain_shared);
1316        let log = Arc::clone(&shared);
1317        let runtime = Pipeline::from_config(test_config(1))
1318            .expect("builder")
1319            .sink(null_sink(1))
1320            .expect("sink")
1321            .chains(move |_| fake_chain(&cs, &log))
1322            .runtime_options(test_options())
1323            .into_runtime(source)
1324            .expect("into_runtime");
1325
1326        let shutdown = runtime.shutdown_handle();
1327        let join = std::thread::spawn(move || runtime.run());
1328        wait_for("source opened", Duration::from_secs(5), || {
1329            shared.lock().unwrap().opened
1330        });
1331        shutdown.trigger();
1332        join.join().unwrap().unwrap();
1333
1334        assert!(
1335            shared.lock().unwrap().stage_metrics_attached,
1336            "the runtime must share SourceMetrics with the source at open, \
1337             or consumer lag can never be published"
1338        );
1339    }
1340
1341    /// The whole-builder happy path through `run()` (not `into_runtime`),
1342    /// exercised over the real SinkPool. Completes and commits.
1343    #[test]
1344    fn run_completes_via_builder_terminal() {
1345        let (source, shared, script) = FakeSource::new();
1346        script
1347            .lock()
1348            .unwrap()
1349            .push_back(Script::Assign(vec![LaneSpec {
1350                id: LaneId(0),
1351                partition: PartitionId(0),
1352                batches: batches(std::slice::from_ref(&(0..5))),
1353            }]));
1354        let chain_shared = Arc::new(ChainShared::default());
1355        let cs = Arc::clone(&chain_shared);
1356        let log = Arc::clone(&shared);
1357
1358        let pipeline = Pipeline::from_config(test_config(1))
1359            .expect("builder")
1360            .sink(null_sink(2))
1361            .expect("sink")
1362            .chains(move |_ctx| fake_chain(&cs, &log))
1363            .runtime_options(test_options());
1364
1365        // Drive shutdown from a watcher thread once the payloads land.
1366        let consumed = Arc::clone(&chain_shared);
1367        let runtime = pipeline.into_runtime(source).expect("into_runtime");
1368        let shutdown = runtime.shutdown_handle();
1369        std::thread::spawn(move || {
1370            wait_for("payloads consumed", Duration::from_secs(5), || {
1371                consumed.consumed.load(std::sync::atomic::Ordering::Relaxed) == 5
1372            });
1373            shutdown.trigger();
1374        });
1375        let report = runtime.run().unwrap();
1376        assert_eq!(report.state, ExitState::Completed);
1377        assert_eq!(report.final_watermarks, vec![(PartitionId(0), 5)]);
1378    }
1379}