1use super::controller::{CLOSE_SHARE, ControllerContext, ControllerSignal, run_controller};
4use super::driver::{DriverContext, DriverExit, DriverParams, run_driver};
5use super::{DriverEvent, ExitReport, ExitState, FatalErrorReport, SinkRuntime, ThreadControl};
6use crate::admin::{AdminServer, HealthState, HealthThresholds};
7use crate::backpressure::{BackpressureParams, InflightBudget, WatermarkController};
8use crate::checkpoint::Checkpointer;
9use crate::clock::{Clock, SystemClock};
10use crate::config::{MetricsExporter, PinningMode, PipelineConfig};
11use crate::feed::memory::MemoryFeed;
12use crate::feed::{
13 DEAD_LETTER_READ_INTERVAL, DeadLetterSampler, Feed, FeedMetricsCtx, FeedOrigin, MeteredFeed,
14 STOP_RESERVE,
15};
16use crate::metrics::{
17 self, BackpressureMetrics, CheckpointMetrics, ComponentLabels, E2eBasis, Exporter, FeedMetrics,
18 InflightBudgetMetrics, IoBusySampler, IoRuntimeMetrics, Meter, MetricRole, MetricsHandle,
19 MetricsSettings, PipelineMetrics, PipelineState, SourceMetrics,
20};
21use crate::ops::RunnableChain;
22use crate::sink::SinkProbeFn;
23use crate::source::{DrainBarrier, Source};
24use crate::task;
25use std::net::SocketAddr;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::time::{Duration, Instant};
29#[cfg(unix)]
30use tokio::signal::unix::{SignalKind, signal};
31use tokio::sync::watch;
32
33#[derive(Clone, Debug)]
36pub struct RuntimeOptions {
37 pub handle_signals: bool,
40 pub max_records: usize,
42 pub poll_timeout: Duration,
44 pub idle_flush: Duration,
46 pub blocked_retry: Duration,
48 pub event_poll_timeout: Duration,
50 pub version: String,
52 pub dead_letter_read_interval: Option<Duration>,
57}
58
59impl Default for RuntimeOptions {
60 fn default() -> Self {
61 RuntimeOptions {
62 handle_signals: true,
63 max_records: 512,
64 poll_timeout: Duration::from_millis(10),
65 idle_flush: Duration::from_millis(100),
66 blocked_retry: Duration::from_millis(2),
67 event_poll_timeout: Duration::from_millis(50),
68 version: env!("CARGO_PKG_VERSION").to_string(),
69 dead_letter_read_interval: Some(DEAD_LETTER_READ_INTERVAL),
70 }
71 }
72}
73
74const IO_BUSY_INTERVAL: Duration = Duration::from_secs(5);
77
78#[derive(Clone, Debug)]
80pub struct ShutdownHandle(Arc<StopRequest>);
81
82impl ShutdownHandle {
83 pub fn trigger(&self) {
87 self.0.trigger();
88 }
89}
90
91pub(crate) struct StopRequest {
94 flag: Arc<AtomicBool>,
95 at: std::sync::Mutex<Option<Instant>>,
96 clock: std::sync::Mutex<Arc<dyn Clock + Send + Sync>>,
97}
98
99impl StopRequest {
100 fn new(clock: Arc<dyn Clock + Send + Sync>) -> StopRequest {
101 StopRequest {
102 flag: Arc::new(AtomicBool::new(false)),
103 at: std::sync::Mutex::new(None),
104 clock: std::sync::Mutex::new(clock),
105 }
106 }
107
108 fn trigger(&self) {
109 {
110 let mut at = self.at.lock().expect("stop request lock");
111 if at.is_none() {
112 *at = Some(self.clock.lock().expect("stop request lock").now());
113 }
114 }
115 self.flag.store(true, Ordering::Release);
117 }
118
119 pub(crate) fn is_set(&self) -> bool {
121 self.flag.load(Ordering::Acquire)
122 }
123
124 pub(crate) fn triggered_at(&self) -> Option<Instant> {
126 *self.at.lock().expect("stop request lock")
127 }
128
129 pub(crate) fn flag(&self) -> Arc<AtomicBool> {
130 Arc::clone(&self.flag)
131 }
132}
133
134impl std::fmt::Debug for StopRequest {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("StopRequest")
137 .field("set", &self.is_set())
138 .field("at", &self.triggered_at())
139 .finish_non_exhaustive()
140 }
141}
142
143#[derive(Debug, thiserror::Error)]
145#[non_exhaustive]
146pub enum StartError {
147 #[error("invalid runtime configuration: {0}")]
149 Config(String),
150 #[error("metrics: {0}")]
152 Metrics(String),
153 #[error("admin.listen: cannot bind {addr}: {source}")]
156 AdminBind {
157 addr: SocketAddr,
159 source: std::io::Error,
161 },
162 #[error("io: {0}")]
164 Io(#[from] std::io::Error),
165}
166
167pub struct PipelineRuntime<S: Source> {
173 config: PipelineConfig,
174 source: S,
175 chains: Box<dyn FnMut(usize) -> Box<dyn RunnableChain> + Send>,
176 sink: SinkRuntime,
177 budget: Arc<InflightBudget>,
178 stop: Arc<StopRequest>,
180 shutdown: Arc<AtomicBool>,
182 stop_clock: Arc<dyn Clock + Send + Sync>,
183 clock: crate::clock::SharedClock,
186 chase_window: Option<Duration>,
189 options: RuntimeOptions,
190 io: Option<tokio::runtime::Runtime>,
191 feed: Option<Arc<dyn Feed>>,
192 io_busy_interval: Duration,
193 io_busy_window: Duration,
194}
195
196impl<S: Source> std::fmt::Debug for PipelineRuntime<S> {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("PipelineRuntime")
199 .field("pipeline", &self.config.pipeline.name)
200 .field("options", &self.options)
201 .finish_non_exhaustive()
202 }
203}
204
205impl<S: Source + 'static> PipelineRuntime<S> {
206 pub fn new(
209 config: PipelineConfig,
210 source: S,
211 chains: impl FnMut(usize) -> Box<dyn RunnableChain> + Send + 'static,
212 sink: SinkRuntime,
213 budget: Arc<InflightBudget>,
214 ) -> Self {
215 PipelineRuntime {
216 config,
217 source,
218 chains: Box::new(chains),
219 sink,
220 budget,
221 stop: Arc::new(StopRequest::new(Arc::new(SystemClock))),
222 shutdown: Arc::new(AtomicBool::new(false)),
223 stop_clock: Arc::new(SystemClock),
224 clock: Arc::new(SystemClock),
225 chase_window: None,
226 options: RuntimeOptions::default(),
227 io: None,
228 feed: None,
229 io_busy_interval: IO_BUSY_INTERVAL,
230 io_busy_window: IoBusySampler::WINDOW,
231 }
232 }
233
234 #[cfg(test)]
237 #[must_use]
238 pub(crate) fn with_io_busy_sampling(mut self, interval: Duration, window: Duration) -> Self {
239 self.io_busy_interval = interval;
240 self.io_busy_window = window;
241 self
242 }
243
244 #[must_use]
246 pub fn with_options(mut self, options: RuntimeOptions) -> Self {
247 self.options = options;
248 self
249 }
250
251 #[must_use]
267 pub fn with_io_runtime(mut self, io: tokio::runtime::Runtime) -> Self {
268 self.io = Some(io);
269 self
270 }
271
272 #[must_use]
276 pub fn with_feed(mut self, feed: Arc<dyn Feed>) -> Self {
277 self.feed = Some(feed);
278 self
279 }
280
281 #[must_use]
283 pub fn shutdown_handle(&self) -> ShutdownHandle {
284 ShutdownHandle(Arc::clone(&self.stop))
285 }
286
287 #[cfg(any(test, feature = "testing"))]
291 #[doc(hidden)]
292 #[must_use]
293 pub fn with_stop_clock(mut self, clock: Arc<dyn Clock + Send + Sync>) -> Self {
294 *self.stop.clock.lock().expect("stop request lock") = Arc::clone(&clock);
295 self.stop_clock = clock;
296 self
297 }
298
299 #[cfg(any(test, feature = "testing"))]
305 #[doc(hidden)]
306 #[must_use]
307 pub fn with_clock(
308 mut self,
309 clock: Arc<dyn Clock + Send + Sync + std::panic::RefUnwindSafe>,
310 ) -> Self {
311 self.clock = clock;
312 self
313 }
314
315 #[cfg(any(test, feature = "testing"))]
318 #[doc(hidden)]
319 #[must_use]
320 pub fn with_chase_window(mut self, window: Duration) -> Self {
321 self.chase_window = Some(window);
322 self
323 }
324
325 fn thread_count(&self) -> usize {
331 self.config.pipeline.threads.unwrap_or_else(|| {
332 let cores = std::thread::available_parallelism().map_or(2, usize::from);
333 default_thread_count(cores, self.config.pipeline.io_threads)
334 })
335 }
336
337 pub fn run(mut self) -> Result<ExitReport, StartError> {
340 let threads = self.thread_count();
341 if threads == 0 || self.config.pipeline.io_threads == 0 {
342 return Err(StartError::Config("thread counts must be non-zero".into()));
343 }
344 if self.io.as_ref().is_some_and(|io| {
345 io.handle().runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread
346 }) {
347 return Err(StartError::Config(
348 "the I/O runtime must be multi-thread: a current-thread runtime runs no \
349 spawned task while `run` waits on the controller"
350 .into(),
351 ));
352 }
353 self.config
354 .reject_location_on_memory()
355 .map_err(|e| StartError::Config(e.to_string()))?;
356 self.config
357 .reject_bad_settling_window()
358 .map_err(|e| StartError::Config(e.to_string()))?;
359 if let (Some(section), Some(feed)) = (&self.config.feed, &self.feed) {
360 let named = section.backend().type_tag();
361 if feed.backend() != named {
362 return Err(StartError::Config(format!(
363 "the feed passed to `Pipeline::feed` is `{}`, but `feed:` names `{named}`",
364 feed.backend()
365 )));
366 }
367 }
368 let mut resolved_feed = None;
369 match (&self.config.feed, self.feed.take()) {
370 (Some(section), given) => {
371 let (feed, origin) = match given {
372 Some(feed) => (feed, FeedOrigin::Given),
373 None if section.backend().type_tag() == "memory" => {
374 let feed = MemoryFeed::from_section(section)
375 .map_err(|e| StartError::Config(e.to_string()))?;
376 (Arc::new(feed) as Arc<dyn Feed>, FeedOrigin::Section)
377 }
378 None => {
379 return Err(StartError::Config(format!(
380 "the `{}` feed backend is built in code: build it from the `feed:` \
381 section and pass it to `Pipeline::feed`",
382 section.backend().type_tag()
383 )));
384 }
385 };
386 let drain_timeout = self.config.checkpoint.drain_timeout;
387 if feed.hands_back() && drain_timeout <= STOP_RESERVE {
388 return Err(StartError::Config(format!(
389 "checkpoint.drain_timeout must be more than {STOP_RESERVE:?}, the stop \
390 reserve of the `{}` feed; found {drain_timeout:?}",
391 feed.backend()
392 )));
393 }
394 resolved_feed = Some((section, feed, origin));
395 }
396 (None, Some(_)) => {
397 return Err(StartError::Config(
398 "a feed was given with `Pipeline::feed`, but the file has no `feed:` section \
399 naming it"
400 .into(),
401 ));
402 }
403 (None, None) => {}
404 }
405 let pipeline_name = self.config.pipeline.name.clone();
406
407 let source_ct = self.source.component_type().to_string();
411 let source_meter = Meter::for_component(
412 &source_ct,
413 MetricRole::Source,
414 pipeline_name.clone(),
415 "source",
416 );
417
418 let handle = install_or_reuse(&metrics_settings(&self.config))?;
420
421 let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
422 let pipeline_metrics = PipelineMetrics::try_new(&runtime_labels, &self.options.version)
426 .map_err(|e| StartError::Metrics(e.to_string()))?;
427 pipeline_metrics.set_state(PipelineState::Starting);
428 pipeline_metrics.set_threads(threads);
429
430 let checkpoint_metrics = CheckpointMetrics::try_new(
435 &ComponentLabels::new(pipeline_name.clone(), "checkpoint", "checkpoint"),
436 self.config.metrics.per_partition_detail,
437 )
438 .map_err(|e| StartError::Metrics(e.to_string()))?;
439 let budget_metrics = InflightBudgetMetrics::try_new(&runtime_labels)
442 .map_err(|e| StartError::Metrics(e.to_string()))?;
443 let io_busy_metrics = IoRuntimeMetrics::try_new(&runtime_labels)
446 .map_err(|e| StartError::Metrics(e.to_string()))?;
447 let controller_source_metrics = Arc::new(
451 SourceMetrics::try_new(&ComponentLabels::new(
452 pipeline_name.clone(),
453 "source",
454 source_ct.clone(),
455 ))
456 .map_err(|e| StartError::Metrics(e.to_string()))?,
457 );
458
459 let kept_feed = match resolved_feed {
463 Some((section, feed, origin)) => {
464 let metrics = FeedMetrics::try_new(&ComponentLabels::new(
465 pipeline_name.clone(),
466 "feed",
467 feed.backend().to_owned(),
468 ))
469 .map_err(|e| StartError::Metrics(e.to_string()))?;
470 let feed = Arc::new(MeteredFeed::new(feed, metrics, Arc::clone(&self.clock)));
471 feed.attach_metrics(
472 FeedMetricsCtx::new(Meter::for_component(
473 feed.backend(),
474 MetricRole::Feed,
475 pipeline_name.clone(),
476 "feed",
477 ))
478 .with_partition_detail(self.config.metrics.per_partition_detail),
479 );
480 self.source
481 .configure_feed(section, Arc::clone(&feed) as Arc<dyn Feed>, origin)
482 .map_err(|e| StartError::Config(e.to_string()))?;
483 Some(feed)
484 }
485 None => None,
486 };
487
488 let health = HealthState::with_clock(
489 threads,
490 HealthThresholds::default(),
491 Arc::clone(&self.clock),
492 );
493
494 let io = match self.io.take() {
501 Some(io) => io,
502 None => tokio::runtime::Builder::new_multi_thread()
503 .worker_threads(self.config.pipeline.io_threads)
504 .thread_name("spate-io")
505 .enable_all()
506 .build()?,
507 };
508
509 let signal_task = self.options.handle_signals.then(|| {
516 let stop = Arc::clone(&self.stop);
517 task::spawn_on(
518 "shutdown-signal",
519 async move {
520 wait_for_signal().await;
521 tracing::info!("shutdown signal received; draining");
522 stop.trigger();
523 },
524 io.handle(),
525 )
526 });
527
528 let probe_task = match self.sink.probe.take() {
532 Some(probe) => Some(task::spawn_on(
533 "sink-probe",
534 probe_sinks(probe, Arc::clone(&health)),
535 io.handle(),
536 )),
537 None => {
538 health.set_sinks_connected(true);
539 None
540 }
541 };
542
543 let (events_tx, events_rx) = crossbeam_channel::unbounded::<DriverEvent>();
545 let (to_main_tx, to_main_rx) = crossbeam_channel::unbounded::<ControllerSignal>();
546 let (sink_drained_tx, sink_drained_rx) = crossbeam_channel::unbounded::<()>();
547 let mut checkpointer = Checkpointer::new();
548 checkpointer.set_clock(Arc::clone(&self.clock));
549
550 let bp_params = BackpressureParams::from_budget(
551 usize::try_from(self.config.backpressure.max_inflight_bytes.as_u64())
552 .unwrap_or(usize::MAX),
553 self.config.backpressure.high_ratio,
554 self.config.backpressure.low_ratio,
555 self.config.backpressure.min_pause,
556 );
557
558 let core_ids: Vec<Option<core_affinity::CoreId>> =
564 if self.config.pipeline.pinning == PinningMode::Compact {
565 let mut ids = core_affinity::get_core_ids().unwrap_or_default();
566 ids.sort_by_key(|c| c.id);
567 if ids.len() < threads {
568 tracing::warn!(
569 cores = ids.len(),
570 threads,
571 "fewer cores than pipeline threads; surplus threads run unpinned"
572 );
573 }
574 (0..threads).map(|i| ids.get(i).copied()).collect()
575 } else {
576 vec![None; threads]
577 };
578
579 let drain_timeout = self.config.checkpoint.drain_timeout;
582
583 let mut control_txs = Vec::with_capacity(threads);
584 let mut driver_handles = Vec::with_capacity(threads);
585 for i in 0..threads {
586 let (control_tx, control_rx) = crossbeam_channel::unbounded::<ThreadControl<S::Lane>>();
587 control_txs.push(control_tx);
588 let ctx = DriverContext {
589 params: DriverParams {
590 thread: i,
591 max_records: self.options.max_records,
592 poll_timeout: self.options.poll_timeout,
593 idle_flush: self.options.idle_flush,
594 blocked_retry: self.options.blocked_retry,
595 queue_low_ratio: self.config.backpressure.low_ratio,
596 max_pending_batches: self.config.checkpoint.max_pending_batches,
597 },
598 control: control_rx,
599 events: events_tx.clone(),
600 chain: (self.chains)(i),
601 bp: WatermarkController::new(bp_params),
602 budget: Arc::clone(&self.budget),
603 queues: self.sink.queues.clone(),
604 health: Arc::clone(&health),
605 bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
608 pipeline_name.clone(),
609 format!("driver-{i}"),
610 "driver",
611 )),
612 source_metrics: SourceMetrics::shadow(&ComponentLabels::new(
619 pipeline_name.clone(),
620 "source",
621 source_ct.clone(),
622 )),
623 shutdown: Arc::clone(&self.shutdown),
624 clock: Arc::clone(&self.clock) as _,
625 stop_clock: Arc::clone(&self.stop_clock),
626 };
627 let core = core_ids.get(i).copied().flatten();
628 #[expect(
629 clippy::disallowed_methods,
630 reason = "`run` joins every driver thread, on each early return too"
631 )]
632 let spawned = std::thread::Builder::new()
633 .name(format!("spate-pipeline-{i}"))
634 .spawn(move || {
635 if let Some(core) = core
636 && !core_affinity::set_for_current(core)
637 {
638 tracing::warn!(core = core.id, "failed to pin pipeline thread");
639 }
640 run_driver(ctx)
641 });
642 match spawned {
643 Ok(handle) => driver_handles.push(handle),
644 Err(e) => {
645 stop_drivers(
647 &self.shutdown,
648 &control_txs,
649 driver_handles,
650 drain_timeout,
651 &*self.stop_clock,
652 );
653 return Err(StartError::Io(e));
654 }
655 }
656 }
657
658 drop(self.chains);
662
663 let control_txs_for_stop = control_txs.clone();
667
668 let (admin_stop_tx, admin_task) = match self.config.admin.listen {
671 Some(addr) => {
672 let render = handle.exports().then(|| handle.render_fn());
673 let admin = match io.block_on(AdminServer::bind(addr, render, Arc::clone(&health)))
674 {
675 Ok(admin) => admin,
676 Err(source) => {
677 stop_drivers(
678 &self.shutdown,
679 &control_txs_for_stop,
680 driver_handles,
681 drain_timeout,
682 &*self.stop_clock,
683 );
684 return Err(StartError::AdminBind { addr, source });
685 }
686 };
687 let (admin_stop_tx, admin_stop_rx) = watch::channel(false);
688 tracing::info!(addr = %admin.local_addr(), "admin server listening");
689 let admin_task =
690 task::spawn_on("admin-server", admin.run(admin_stop_rx), io.handle());
691 (Some(admin_stop_tx), Some(admin_task))
692 }
693 None => {
694 if handle.exports() {
695 tracing::warn!(
696 "an exporter is installed but `admin.listen` is `none`, so \
697 nothing serves /metrics; render the exposition through \
698 Pipeline::metrics(), or set `metrics.exporter: none`"
699 );
700 }
701 (None, None)
702 }
703 };
704 let upkeep_task = {
705 let _guard = io.enter();
706 handle.spawn_upkeep(Duration::from_secs(5))
707 };
708
709 let controller_ctx = ControllerContext {
710 source: self.source,
711 checkpointer,
712 control_txs,
713 events_rx,
714 to_main: to_main_tx,
715 sink_drained_rx,
716 stop: Arc::clone(&self.stop),
717 shutdown: Arc::clone(&self.shutdown),
718 stop_clock: Arc::clone(&self.stop_clock),
719 clock: Arc::clone(&self.clock) as _,
720 close_share: if kept_feed.as_ref().is_some_and(|f| f.hands_back()) {
721 CLOSE_SHARE
722 } else {
723 Duration::ZERO
724 },
725 health: Arc::clone(&health),
726 commit_interval: self.config.checkpoint.interval,
727 chase_window: self.chase_window.unwrap_or(self.config.checkpoint.interval),
728 drain_timeout: self.config.checkpoint.drain_timeout,
729 event_poll_timeout: self.options.event_poll_timeout,
730 stalled_fail_after: self.config.checkpoint.stalled_fail_after,
731 sink_failures: self.sink.failures.clone(),
732 checkpoint_metrics,
733 budget: Arc::clone(&self.budget),
734 budget_metrics,
735 source_metrics: controller_source_metrics,
736 source_meter,
737 per_partition_detail: self.config.metrics.per_partition_detail,
738 pipeline_metrics,
739 };
740 #[expect(
741 clippy::disallowed_methods,
742 reason = "`run` joins the controller after the driver threads, and on each early return"
743 )]
744 let controller_handle = match std::thread::Builder::new()
745 .name("spate-controller".into())
746 .spawn(move || run_controller(controller_ctx))
747 {
748 Ok(handle) => handle,
749 Err(e) => {
750 stop_drivers(
751 &self.shutdown,
752 &control_txs_for_stop,
753 driver_handles,
754 drain_timeout,
755 &*self.stop_clock,
756 );
757 return Err(StartError::Io(e));
758 }
759 };
760
761 let mut sink_drain = None;
763 let mut driver_panic: Option<FatalErrorReport> = None;
764 let sink_runtime = self.sink;
765 let mut drain_fn = Some(sink_runtime.drain);
766 drop(sink_runtime.queues);
767
768 let io_runtime_metrics = io.handle().metrics();
772 let mut io_busy = IoBusySampler::new(
773 self.io_busy_interval,
774 self.io_busy_window,
775 SystemClock.now(),
777 metrics::io_busy_reading(&io_runtime_metrics).0,
778 );
779 let mut dead_letters = self
780 .options
781 .dead_letter_read_interval
782 .map(|interval| DeadLetterSampler::new(interval, self.clock.now()));
783
784 let (mut state, final_watermarks, close_by) = loop {
785 let (busy, workers) = metrics::io_busy_reading(&io_runtime_metrics);
786 sample_io_busy(
787 &mut io_busy,
788 &io_busy_metrics,
789 SystemClock.now(),
790 busy,
791 workers,
792 &pipeline_name,
793 );
794 if let (Some(feed), Some(sampler)) = (&kept_feed, &mut dead_letters) {
795 sampler.sample(feed, self.clock.now());
796 }
797 match to_main_rx.recv_timeout(Duration::from_millis(100)) {
798 Ok(ControllerSignal::LanesDrained { sink_deadline }) => {
799 for (i, h) in driver_handles.drain(..).enumerate() {
800 if h.join().is_err() {
801 driver_panic.get_or_insert(FatalErrorReport {
802 component: format!("driver-{i}"),
803 reason: "pipeline thread panicked outside the batch guard".into(),
804 });
805 }
806 }
807 if let Some(drain) = drain_fn.take() {
808 let budget = sink_deadline.saturating_duration_since(self.stop_clock.now());
809 sink_drain = Some(io.block_on(drain(budget)));
810 }
811 let _ = sink_drained_tx.send(());
812 }
813 Ok(ControllerSignal::Finished(report)) => {
814 break (report.state, report.final_watermarks, Some(report.close_by));
815 }
816 Err(crossbeam_channel::RecvTimeoutError::Timeout)
817 if !controller_handle.is_finished() =>
818 {
819 }
821 Err(_) => {
822 stop_drivers(
829 &self.shutdown,
830 &control_txs_for_stop,
831 std::mem::take(&mut driver_handles),
832 drain_timeout,
833 &*self.stop_clock,
834 );
835 if let Some(drain) = drain_fn.take() {
836 sink_drain = Some(io.block_on(drain(drain_timeout)));
837 }
838 break (
839 ExitState::Failed(FatalErrorReport {
840 component: "controller".into(),
841 reason: "controller thread panicked".into(),
842 }),
843 Vec::new(),
844 None,
845 );
846 }
847 }
848 };
849 for h in driver_handles {
852 let _ = h.join();
853 }
854 if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
857 state = ExitState::Failed(report);
858 }
859
860 if let (Some(feed), Some(close_by)) = (&kept_feed, close_by) {
863 close_feed(
864 &io,
865 feed.as_ref(),
866 close_by.saturating_duration_since(self.stop_clock.now()),
867 );
868 }
869 if let Some(tx) = &admin_stop_tx {
870 let _ = tx.send(true);
871 }
872 let _ = controller_handle.join();
876 drop((signal_task, probe_task, admin_task, upkeep_task));
877 io.shutdown_timeout(Duration::from_secs(2));
878
879 Ok(ExitReport {
880 state,
881 sink_drain,
882 final_watermarks,
883 })
884 }
885}
886
887pub(super) fn default_thread_count(cores: usize, io_threads: usize) -> usize {
901 cores.saturating_sub(io_threads.div_ceil(2) + 1).max(1)
902}
903
904pub(super) fn sample_io_busy(
910 sampler: &mut IoBusySampler,
911 gauge: &IoRuntimeMetrics,
912 now: Instant,
913 busy_total: Duration,
914 io_workers: usize,
915 pipeline: &str,
916) {
917 let Some(sample) = sampler.observe(now, busy_total, io_workers) else {
918 return;
919 };
920 gauge.set_busy_ratio(sample.ratio);
921 if sample.sustained {
922 tracing::warn!(
923 pipeline,
924 io_workers,
925 ratio = sample.ratio,
926 "the I/O runtime's workers have been saturated for five minutes; if the \
927 sink queues are filling, raise `pipeline.io_threads`"
928 );
929 }
930}
931
932fn close_feed(io: &tokio::runtime::Runtime, feed: &dyn Feed, within: Duration) {
935 let Some(confirmation) = feed.close(within) else {
936 return;
937 };
938 match io.block_on(async { tokio::time::timeout(within, confirmation).await }) {
940 Ok(Ok(())) => {}
941 Ok(Err(e)) => tracing::warn!(
942 feed = feed.backend(),
943 error = %e,
944 "the feed did not close cleanly; the units it still held return through redelivery"
945 ),
946 Err(_) => tracing::warn!(
947 feed = feed.backend(),
948 ?within,
949 "the feed did not close before the drain deadline; the units it still held return \
950 through redelivery"
951 ),
952 }
953}
954
955fn stop_drivers<L>(
965 shutdown: &AtomicBool,
966 control_txs: &[crossbeam_channel::Sender<ThreadControl<L>>],
967 driver_handles: Vec<std::thread::JoinHandle<DriverExit>>,
968 grace: Duration,
969 stop_clock: &dyn Clock,
970) {
971 shutdown.store(true, Ordering::Relaxed);
972 let deadline = stop_clock.now() + grace;
973 let barrier = DrainBarrier::new(control_txs.len());
974 for tx in control_txs {
975 let _ = tx.send(ThreadControl::Shutdown {
976 barrier: barrier.clone(),
977 deadline,
978 });
979 }
980 for handle in driver_handles {
981 let _ = handle.join();
982 }
983}
984
985pub(crate) fn install_or_reuse(settings: &MetricsSettings) -> Result<MetricsHandle, StartError> {
990 match metrics::install(settings) {
991 Ok(h) => Ok(h),
992 Err(metrics::MetricsError::AlreadyInstalled) => {
993 tracing::warn!(
994 "a metrics recorder is already installed; continuing \
995 with the existing one and a detached render handle"
996 );
997 metrics::install(&MetricsSettings {
998 exporter: Exporter::None,
999 ..settings.clone()
1000 })
1001 .map_err(|e| StartError::Metrics(e.to_string()))
1002 }
1003 Err(e) => Err(StartError::Metrics(e.to_string())),
1004 }
1005}
1006
1007#[must_use]
1016pub fn metrics_settings(config: &PipelineConfig) -> MetricsSettings {
1017 MetricsSettings {
1018 exporter: match config.metrics.exporter {
1019 MetricsExporter::Prometheus => Exporter::Prometheus,
1020 MetricsExporter::None => Exporter::None,
1021 },
1022 per_partition_detail: config.metrics.per_partition_detail,
1023 e2e_basis: match config.metrics.e2e_basis {
1024 crate::config::E2eBasis::Ingest => E2eBasis::Ingest,
1025 crate::config::E2eBasis::Event => E2eBasis::Event,
1026 },
1027 }
1028}
1029
1030pub(crate) async fn probe_sinks(probe: SinkProbeFn, health: Arc<HealthState>) {
1036 struct NotConnectedOnPanic<'a>(&'a HealthState);
1037 impl Drop for NotConnectedOnPanic<'_> {
1038 fn drop(&mut self) {
1039 if std::thread::panicking() {
1040 self.0.set_sinks_connected(false);
1041 }
1042 }
1043 }
1044 let _guard = NotConnectedOnPanic(&health);
1045 loop {
1046 let connected = match probe().await {
1047 Ok(()) => true,
1048 Err(e) => {
1049 tracing::warn!(error = %e, "sink probe failed");
1050 false
1051 }
1052 };
1053 health.set_sinks_connected(connected);
1054 let recheck = if connected {
1055 Duration::from_secs(30)
1056 } else {
1057 Duration::from_secs(5)
1058 };
1059 tokio::time::sleep(recheck).await;
1060 }
1061}
1062
1063async fn wait_for_signal() {
1064 #[cfg(unix)]
1065 {
1066 let mut term = match signal(SignalKind::terminate()) {
1067 Ok(s) => s,
1068 Err(e) => {
1069 tracing::error!(error = %e, "failed to install SIGTERM handler");
1070 std::future::pending::<()>().await;
1071 return;
1072 }
1073 };
1074 tokio::select! {
1075 _ = term.recv() => {}
1076 r = tokio::signal::ctrl_c() => {
1077 if let Err(e) = r {
1078 tracing::error!(error = %e, "ctrl_c handler failed");
1079 std::future::pending::<()>().await;
1080 }
1081 }
1082 }
1083 }
1084 #[cfg(not(unix))]
1085 {
1086 if let Err(e) = tokio::signal::ctrl_c().await {
1087 tracing::error!(error = %e, "ctrl_c handler failed");
1088 std::future::pending::<()>().await;
1089 }
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use crate::pipeline::fakes::{FakeSource, test_config};
1097
1098 #[test]
1101 fn the_io_busy_sampling_defaults_to_five_seconds_over_five_minutes() {
1102 let (source, _log, _script) = FakeSource::new();
1103 let (queues, _receivers) = crate::sink::shard_queues(1, 1);
1104 let sink = SinkRuntime {
1105 queues: vec![queues],
1106 drain: Box::new(|_budget| Box::pin(async { crate::sink::DrainReport::default() })),
1107 probe: None,
1108 failures: crate::sink::SinkFailures::new(),
1109 };
1110 let runtime = PipelineRuntime::new(
1111 test_config(1),
1112 source,
1113 |_thread| unreachable!("the runtime is never run"),
1114 sink,
1115 Arc::new(InflightBudget::new()),
1116 );
1117
1118 assert_eq!(runtime.io_busy_interval, Duration::from_secs(5));
1119 assert_eq!(runtime.io_busy_window, IoBusySampler::WINDOW);
1120 assert_eq!(IoBusySampler::WINDOW, Duration::from_secs(300));
1121 }
1122}