1use super::SinkRuntime;
56use super::runtime::{
57 PipelineRuntime, RuntimeOptions, StartError, install_or_reuse, metrics_settings,
58};
59use crate::backpressure::InflightBudget;
60use crate::config::{ConfigError, PipelineConfig};
61use crate::metrics::{ComponentLabels, MetricsHandle, SinkShardMetrics};
62use crate::ops::RunnableChain;
63use crate::pipeline::ExitReport;
64use crate::sink::{ShardQueues, SinkBundle, SinkDrainFn, SinkPool, SinkProbeFn, shard_queues};
65use crate::source::Source;
66use crate::telemetry::{self, LogFormat};
67use std::path::Path;
68use std::sync::Arc;
69
70#[derive(Debug, thiserror::Error)]
72#[non_exhaustive]
73pub enum BuildError {
74 #[error(transparent)]
76 Config(#[from] ConfigError),
77 #[error("metrics: {0}")]
79 Metrics(String),
80 #[error("io runtime: {0}")]
82 Io(#[from] std::io::Error),
83 #[error("sink: {0}")]
85 Sink(String),
86 #[error("a sink is already installed")]
88 SinkAlreadySet,
89 #[error("no sink installed (call Pipeline::sink first)")]
91 MissingSink,
92 #[error("no chain factory installed (call Pipeline::chains first)")]
94 MissingChains,
95 #[error(
100 "Pipeline::from_config must be called outside any async runtime \
101 (it owns a blocking tokio runtime)"
102 )]
103 AsyncContext,
104}
105
106#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum PipelineError {
110 #[error(transparent)]
112 Build(#[from] BuildError),
113 #[error(transparent)]
115 Start(#[from] StartError),
116}
117
118#[derive(Debug)]
126#[non_exhaustive]
127pub struct ChainCtx {
128 pub thread: usize,
130 pub queues: ShardQueues,
132 pub budget: Arc<InflightBudget>,
134 pub pipeline: String,
137}
138
139#[derive(Clone, Debug)]
141#[non_exhaustive]
142pub struct SinkOptions {
143 pub queue_capacity: usize,
146}
147
148impl SinkOptions {
149 #[must_use]
152 pub fn with_queue_capacity(mut self, capacity: usize) -> Self {
153 self.queue_capacity = capacity;
154 self
155 }
156}
157
158impl Default for SinkOptions {
159 fn default() -> Self {
160 SinkOptions { queue_capacity: 8 }
161 }
162}
163
164struct SinkAssembly {
165 queues: ShardQueues,
166 drain: SinkDrainFn,
167 probe: Option<SinkProbeFn>,
168}
169
170type ChainFactoryFn = Box<dyn FnMut(ChainCtx) -> Box<dyn RunnableChain> + Send>;
171
172pub struct Pipeline {
177 config: PipelineConfig,
178 metrics: MetricsHandle,
179 io: tokio::runtime::Runtime,
180 budget: Arc<InflightBudget>,
181 sink: Option<SinkAssembly>,
182 chains: Option<ChainFactoryFn>,
183 options: RuntimeOptions,
184}
185
186impl std::fmt::Debug for Pipeline {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("Pipeline")
189 .field("pipeline", &self.config.pipeline.name)
190 .field("sink", &self.sink.is_some())
191 .field("chains", &self.chains.is_some())
192 .finish_non_exhaustive()
193 }
194}
195
196impl Pipeline {
197 pub fn from_path(path: &Path) -> Result<Self, BuildError> {
200 Self::from_config(PipelineConfig::from_path(path)?)
201 }
202
203 pub fn from_config(config: PipelineConfig) -> Result<Self, BuildError> {
223 if tokio::runtime::Handle::try_current().is_ok() {
224 return Err(BuildError::AsyncContext);
225 }
226 if config.pipeline.io_threads == 0 {
227 return Err(BuildError::Config(ConfigError::Validation(
228 "pipeline.io_threads must be non-zero".into(),
229 )));
230 }
231 telemetry::init(LogFormat::Json, "info");
232 let metrics = install_or_reuse(&metrics_settings(&config)).map_err(|e| match e {
233 StartError::Metrics(m) => BuildError::Metrics(m),
234 other => BuildError::Metrics(other.to_string()),
235 })?;
236 let io = tokio::runtime::Builder::new_multi_thread()
237 .worker_threads(config.pipeline.io_threads)
238 .thread_name("etl-io")
239 .enable_all()
240 .build()?;
241 Ok(Pipeline {
242 config,
243 metrics,
244 io,
245 budget: Arc::new(InflightBudget::new()),
246 sink: None,
247 chains: None,
248 options: RuntimeOptions::default(),
249 })
250 }
251
252 #[must_use]
256 pub fn config(&self) -> &PipelineConfig {
257 &self.config
258 }
259
260 #[must_use]
262 pub fn metrics(&self) -> &MetricsHandle {
263 &self.metrics
264 }
265
266 #[must_use]
268 pub fn budget(&self) -> &Arc<InflightBudget> {
269 &self.budget
270 }
271
272 #[must_use]
276 pub fn io_handle(&self) -> tokio::runtime::Handle {
277 self.io.handle().clone()
278 }
279
280 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
283 self.io.block_on(future)
284 }
285
286 pub fn sink<B: SinkBundle>(self, bundle: B) -> Result<Self, BuildError> {
289 self.sink_with(bundle, SinkOptions::default())
290 }
291
292 pub fn sink_with<B: SinkBundle>(
303 mut self,
304 bundle: B,
305 options: SinkOptions,
306 ) -> Result<Self, BuildError> {
307 if self.sink.is_some() {
308 return Err(BuildError::SinkAlreadySet);
309 }
310 if options.queue_capacity == 0 {
311 return Err(BuildError::Sink("queue_capacity must be non-zero".into()));
312 }
313 let parts = bundle.into_parts();
314 let num_shards = parts.shard_endpoints.len();
315 if num_shards == 0 {
316 return Err(BuildError::Sink("sink topology has no shards".into()));
317 }
318 if let Some(shard) = parts.shard_endpoints.iter().position(Vec::is_empty) {
319 return Err(BuildError::Sink(format!("shard {shard} has no replicas")));
320 }
321 let replica_labels = parts.effective_replica_labels();
322 let label_shape: Vec<usize> = replica_labels.iter().map(Vec::len).collect();
323 let endpoint_shape: Vec<usize> = parts.shard_endpoints.iter().map(Vec::len).collect();
324 if label_shape != endpoint_shape {
325 return Err(BuildError::Sink(format!(
326 "replica_labels shape {label_shape:?} does not match the \
327 endpoint topology {endpoint_shape:?}"
328 )));
329 }
330
331 let name = self.config.pipeline.name.clone();
332 let (queues, receivers) = shard_queues(num_shards, options.queue_capacity);
333 let sink_labels = ComponentLabels::new(name.clone(), "sink", parts.component_type.clone());
334 let e2e_basis = metrics_settings(&self.config).e2e_basis;
335 let shard_metrics: Vec<SinkShardMetrics> = replica_labels
336 .iter()
337 .enumerate()
338 .map(|(shard, replicas)| {
339 SinkShardMetrics::new(
340 &sink_labels,
341 u32::try_from(shard).unwrap_or(u32::MAX),
342 replicas,
343 e2e_basis,
344 )
345 })
346 .collect();
347 let pool = SinkPool::spawn(
348 Arc::new(parts.writer),
349 parts.shard_endpoints,
350 receivers,
351 parts.pool,
352 Arc::clone(&self.budget),
353 shard_metrics,
354 &name,
355 self.io.handle(),
356 );
357 self.sink = Some(SinkAssembly {
358 queues,
359 drain: Box::new(move |deadline| Box::pin(async move { pool.drain(deadline).await })),
360 probe: parts.probe,
361 });
362 Ok(self)
363 }
364
365 #[must_use]
371 pub fn chains<F>(mut self, factory: F) -> Self
372 where
373 F: FnMut(ChainCtx) -> Box<dyn RunnableChain> + Send + 'static,
374 {
375 self.chains = Some(Box::new(factory));
376 self
377 }
378
379 #[must_use]
381 pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
382 self.options = options;
383 self
384 }
385
386 pub fn into_runtime<S: Source + 'static>(
396 mut self,
397 source: S,
398 ) -> Result<PipelineRuntime<S>, BuildError> {
399 let assembly = self.sink.take().ok_or(BuildError::MissingSink)?;
400 let mut factory = self.chains.take().ok_or(BuildError::MissingChains)?;
401 let queues = assembly.queues.clone();
402 let budget = Arc::clone(&self.budget);
403 let name = self.config.pipeline.name.clone();
404 let chains = move |thread: usize| {
407 factory(ChainCtx {
408 thread,
409 queues: queues.clone(),
410 budget: Arc::clone(&budget),
411 pipeline: name.clone(),
412 })
413 };
414 Ok(PipelineRuntime::new(
415 self.config,
416 source,
417 chains,
418 SinkRuntime {
419 queues: assembly.queues,
420 drain: assembly.drain,
421 probe: assembly.probe,
422 },
423 self.budget,
424 )
425 .with_options(self.options)
426 .with_io_runtime(self.io))
427 }
428
429 pub fn run<S: Source + 'static>(self, source: S) -> Result<ExitReport, PipelineError> {
433 Ok(self.into_runtime(source)?.run()?)
434 }
435}
436
437#[cfg(all(test, not(loom)))]
438mod tests {
439 use super::*;
440 use crate::error::SinkError;
441 use crate::pipeline::ExitState;
442 use crate::pipeline::fakes::{
443 ChainMode, ChainShared, FakeChain, FakeSource, LaneSpec, Script, SourceLog, batches,
444 test_config, test_options, wait_for,
445 };
446 use crate::record::PartitionId;
447 use crate::sink::{SealedBatch, SinkParts, SinkPoolConfig};
448 use crate::source::LaneId;
449 use std::sync::Mutex;
450 use std::time::Duration;
451
452 struct NullWriter;
453 impl crate::sink::ShardWriter for NullWriter {
454 type Endpoint = ();
455 async fn write_batch(&self, (): &(), _batch: &SealedBatch) -> Result<(), SinkError> {
456 Ok(())
457 }
458 }
459
460 fn null_sink(shards: usize) -> SinkParts<NullWriter> {
461 SinkParts::new(
462 NullWriter,
463 (0..shards).map(|_| vec![()]).collect(),
464 SinkPoolConfig::default(),
465 )
466 .with_component_type("null")
467 }
468
469 fn fake_chain(
470 shared: &Arc<ChainShared>,
471 log: &Arc<Mutex<SourceLog>>,
472 ) -> Box<dyn RunnableChain> {
473 Box::new(FakeChain {
474 shared: Arc::clone(shared),
475 log: Arc::clone(log),
476 mode: ChainMode::Ok,
477 batches_seen: 0,
478 })
479 }
480
481 #[test]
482 fn missing_sink_then_missing_chains_error() {
483 let (source, _shared, _script) = FakeSource::new();
484 let p = Pipeline::from_config(test_config(1)).expect("builder");
485 assert!(matches!(
486 p.into_runtime(source).err(),
487 Some(BuildError::MissingSink)
488 ));
489
490 let (source, _shared, _script) = FakeSource::new();
491 let p = Pipeline::from_config(test_config(1))
492 .expect("builder")
493 .sink(null_sink(1))
494 .expect("sink");
495 assert!(matches!(
496 p.into_runtime(source).err(),
497 Some(BuildError::MissingChains)
498 ));
499 }
500
501 #[test]
502 fn second_sink_errors() {
503 let p = Pipeline::from_config(test_config(1))
504 .expect("builder")
505 .sink(null_sink(1))
506 .expect("first sink");
507 assert!(matches!(
508 p.sink(null_sink(1)).err(),
509 Some(BuildError::SinkAlreadySet)
510 ));
511 }
512
513 #[test]
514 fn bad_topologies_error_instead_of_panicking() {
515 let p = Pipeline::from_config(test_config(1)).expect("builder");
516 let empty = SinkParts::new(NullWriter, Vec::new(), SinkPoolConfig::default());
517 assert!(matches!(p.sink(empty).err(), Some(BuildError::Sink(_))));
518
519 let p = Pipeline::from_config(test_config(1)).expect("builder");
520 let ragged = SinkParts::new(
521 NullWriter,
522 vec![vec![()], vec![]],
523 SinkPoolConfig::default(),
524 );
525 assert!(matches!(p.sink(ragged).err(), Some(BuildError::Sink(_))));
526
527 let p = Pipeline::from_config(test_config(1)).expect("builder");
528 let bad_labels = SinkParts::new(NullWriter, vec![vec![()]], SinkPoolConfig::default())
529 .with_replica_labels(vec![vec!["a".into(), "b".into()]]);
530 assert!(matches!(
531 p.sink(bad_labels).err(),
532 Some(BuildError::Sink(_))
533 ));
534
535 let p = Pipeline::from_config(test_config(1)).expect("builder");
536 assert!(matches!(
537 p.sink_with(null_sink(1), SinkOptions { queue_capacity: 0 })
538 .err(),
539 Some(BuildError::Sink(_))
540 ));
541 }
542
543 #[tokio::test]
544 async fn from_config_inside_async_context_errors() {
545 assert!(matches!(
546 Pipeline::from_config(test_config(1)).err(),
547 Some(BuildError::AsyncContext)
548 ));
549 }
550
551 #[test]
564 fn chain_ctx_covers_every_thread_and_run_completes() {
565 let (source, shared, script) = FakeSource::new();
566 script
567 .lock()
568 .unwrap()
569 .push_back(Script::Assign(vec![LaneSpec {
570 id: LaneId(0),
571 partition: PartitionId(0),
572 batches: batches(&[0..10, 10..20]),
573 }]));
574 let chain_shared = Arc::new(ChainShared::default());
575 let seen_threads: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
576
577 let cs = Arc::clone(&chain_shared);
578 let log = Arc::clone(&shared);
579 let seen = Arc::clone(&seen_threads);
580 let runtime = Pipeline::from_config(test_config(2))
581 .expect("builder")
582 .sink(null_sink(1))
583 .expect("sink")
584 .chains(move |ctx| {
585 assert_eq!(ctx.pipeline, "test");
586 seen.lock().unwrap().push(ctx.thread);
587 fake_chain(&cs, &log)
588 })
589 .runtime_options(test_options())
590 .into_runtime(source)
591 .expect("into_runtime");
592
593 let shutdown = runtime.shutdown_handle();
594 let join = std::thread::spawn(move || runtime.run());
595 wait_for("payloads consumed", Duration::from_secs(5), || {
596 chain_shared
597 .consumed
598 .load(std::sync::atomic::Ordering::Relaxed)
599 == 20
600 });
601 shutdown.trigger();
602 let report = join.join().unwrap().unwrap();
603 assert_eq!(report.state, ExitState::Completed);
604
605 let mut threads = seen_threads.lock().unwrap().clone();
606 threads.sort_unstable();
607 assert_eq!(threads, vec![0, 1], "one ChainCtx per pipeline thread");
608 }
609
610 #[test]
613 fn run_completes_via_builder_terminal() {
614 let (source, shared, script) = FakeSource::new();
615 script
616 .lock()
617 .unwrap()
618 .push_back(Script::Assign(vec![LaneSpec {
619 id: LaneId(0),
620 partition: PartitionId(0),
621 batches: batches(std::slice::from_ref(&(0..5))),
622 }]));
623 let chain_shared = Arc::new(ChainShared::default());
624 let cs = Arc::clone(&chain_shared);
625 let log = Arc::clone(&shared);
626
627 let pipeline = Pipeline::from_config(test_config(1))
628 .expect("builder")
629 .sink(null_sink(2))
630 .expect("sink")
631 .chains(move |_ctx| fake_chain(&cs, &log))
632 .runtime_options(test_options());
633
634 let consumed = Arc::clone(&chain_shared);
636 let runtime = pipeline.into_runtime(source).expect("into_runtime");
637 let shutdown = runtime.shutdown_handle();
638 std::thread::spawn(move || {
639 wait_for("payloads consumed", Duration::from_secs(5), || {
640 consumed.consumed.load(std::sync::atomic::Ordering::Relaxed) == 5
641 });
642 shutdown.trigger();
643 });
644 let report = runtime.run().unwrap();
645 assert_eq!(report.state, ExitState::Completed);
646 assert_eq!(report.final_watermarks, vec![(PartitionId(0), 5)]);
647 }
648}