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