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