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