Skip to main content

spate_core/pipeline/
runtime.rs

1//! Process assembly: threads, runtimes, observability, and the run loop.
2
3use 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/// Knobs that are not part of the user-facing YAML (loop granularities,
34/// test hooks). The defaults suit production; tests shrink the timings.
35#[derive(Clone, Debug)]
36pub struct RuntimeOptions {
37    /// Install SIGTERM/SIGINT handlers that trigger a graceful drain.
38    /// Disable in tests and drive [`ShutdownHandle`] instead.
39    pub handle_signals: bool,
40    /// Max payloads per lane poll.
41    pub max_records: usize,
42    /// Lane poll timeout (also the paused/idle loop sleep).
43    pub poll_timeout: Duration,
44    /// Flush the chain after this long without new data.
45    pub idle_flush: Duration,
46    /// Sleep between retries of a blocked batch.
47    pub blocked_retry: Duration,
48    /// Controller `poll_events` timeout.
49    pub event_poll_timeout: Duration,
50    /// Version string published on `spate_pipeline_info`.
51    pub version: String,
52    /// How often to read the depth of the feed's dead-letter destination for
53    /// `spate_feed_dead_letter_units`, starting at startup. A zero interval
54    /// reads whenever no read is pending. `None` makes no such read, so every
55    /// depth read the feed answers is its source's.
56    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
74/// How often the main thread samples `spate_pipeline_io_busy_ratio`; the
75/// upkeep cadence.
76const IO_BUSY_INTERVAL: Duration = Duration::from_secs(5);
77
78/// Triggers a graceful drain from anywhere (tests, custom signal wiring).
79#[derive(Clone, Debug)]
80pub struct ShutdownHandle(Arc<StopRequest>);
81
82impl ShutdownHandle {
83    /// Begin the graceful stop: a source that reads on what it holds
84    /// finishes first, then the drain runs. The drain deadline counts from
85    /// the first call. Idempotent.
86    pub fn trigger(&self) {
87        self.0.trigger();
88    }
89}
90
91/// A graceful stop's request: the flag the controller and `StopSignal` read,
92/// and the instant of the first trigger, read on the stop clock.
93pub(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        // Stored after the instant, so a reader that sees the flag finds it.
116        self.flag.store(true, Ordering::Release);
117    }
118
119    /// Whether a stop was asked for.
120    pub(crate) fn is_set(&self) -> bool {
121        self.flag.load(Ordering::Acquire)
122    }
123
124    /// When the stop was first asked for, if it was.
125    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/// The pipeline could not start.
144#[derive(Debug, thiserror::Error)]
145#[non_exhaustive]
146pub enum StartError {
147    /// Invalid effective configuration.
148    #[error("invalid runtime configuration: {0}")]
149    Config(String),
150    /// The metrics exporter could not be installed.
151    #[error("metrics: {0}")]
152    Metrics(String),
153    /// The admin server could not take its address. Set `admin.listen` to a
154    /// free address, or to `none` for a pipeline that needs no server.
155    #[error("admin.listen: cannot bind {addr}: {source}")]
156    AdminBind {
157        /// The address `admin.listen` asked for.
158        addr: SocketAddr,
159        /// Why the kernel refused it.
160        source: std::io::Error,
161    },
162    /// The I/O runtime or a pipeline thread could not start.
163    #[error("io: {0}")]
164    Io(#[from] std::io::Error),
165}
166
167/// One pipeline process: source, per-thread chains, and a sink.
168///
169/// The caller creates the shared [`InflightBudget`] first and wires it into
170/// the chain terminals (which `add` on enqueue) and the sink workers (which
171/// `sub` on durable write or abandonment) before handing everything here.
172pub 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    // Set by a stop request; the controller reads on to the reserve.
179    stop: Arc<StopRequest>,
180    // Set when the drain begins; the drivers read it.
181    shutdown: Arc<AtomicBool>,
182    stop_clock: Arc<dyn Clock + Send + Sync>,
183    // Drives the commit tick, the fast-commit window, the stall check, the
184    // idle flush and the health probe.
185    clock: crate::clock::SharedClock,
186    // How long a `CommitReady` partition keeps the tightened cadence; `None`
187    // is one `checkpoint.interval`.
188    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    /// Assemble a runtime. `chains` builds one erased chain per pipeline
207    /// thread (thread index in).
208    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    /// Sample the I/O runtime's busy ratio every `interval`, averaged over
235    /// the trailing `window`.
236    #[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    /// Override runtime options (returns `self` for chaining).
245    #[must_use]
246    pub fn with_options(mut self, options: RuntimeOptions) -> Self {
247        self.options = options;
248        self
249    }
250
251    /// Use a caller-owned tokio runtime as the I/O runtime instead of
252    /// building one inside [`run`](Self::run). For assemblies whose
253    /// connectors needed a handle before the runtime existed (sink workers
254    /// spawned at construction, schema-registry fetchers, async pre-flight
255    /// validation). `run` shuts it down on exit as it does the internally
256    /// built one; connector tasks spawned on it earlier keep running until
257    /// then. Without this, assemblies end up running a second runtime,
258    /// doubling `pipeline.io_threads`.
259    ///
260    /// The runtime must be multi-thread, built with
261    /// [`tokio::runtime::Builder::new_multi_thread`]. `run` waits on the
262    /// controller outside `block_on`, and a current-thread runtime runs no
263    /// spawned task then, so sink workers, the admin server and upkeep would
264    /// stall while records flow. `run` returns [`StartError::Config`] for a
265    /// current-thread runtime.
266    #[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    /// Hand the source a feed built in code, for the config's `feed:`
273    /// section to name; see [`Pipeline::feed`](super::Pipeline::feed) for
274    /// the rules `run` checks.
275    #[must_use]
276    pub fn with_feed(mut self, feed: Arc<dyn Feed>) -> Self {
277        self.feed = Some(feed);
278        self
279    }
280
281    /// A handle that triggers a graceful drain.
282    #[must_use]
283    pub fn shutdown_handle(&self) -> ShutdownHandle {
284        ShutdownHandle(Arc::clone(&self.stop))
285    }
286
287    /// Read a graceful stop's reserve and deadlines, the revocation drain
288    /// deadline, the startup-error grace and the sink's drain budget on
289    /// `clock`, so a test moves them by hand.
290    #[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    /// Read the commit tick, the fast-commit window, the stall check, the
300    /// idle flush, the health probe, the feed's call timings and its
301    /// dead-letter reads on `clock`, so a test moves them by hand. Every drain
302    /// deadline is computed on the stop clock set by `with_stop_clock`, and the
303    /// sink pool counts its share down on its own tokio clock.
304    #[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    /// Close each `CommitReady` partition's tightened window after `window`,
316    /// so a test reaches the end of a window without a clock.
317    #[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    /// Effective pipeline thread count: the config override, else
326    /// [`default_thread_count`] over `available_parallelism`.
327    /// `available_parallelism` respects cgroup CPU quotas, so Kubernetes
328    /// limits size this correctly. Pods without limits see the node's cores;
329    /// set `pipeline.threads` explicitly there.
330    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    /// Run the pipeline to completion (blocking). Returns when the
338    /// pipeline drained after a shutdown trigger/signal or failed.
339    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        // The source's declared component_type feeds both its stage-metric
408        // labels and the namespace of the custom-metrics Meter it receives at
409        // `open`. Captured here, before `self.source` moves into the controller.
410        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        // Observability first: everything after this records metrics.
419        let handle = install_or_reuse(&metrics_settings(&self.config))?;
420
421        let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
422        // Fallible: a second live pipeline of this name in one process would
423        // publish state and lifecycle gauges over this one's (see "Series
424        // ownership" in `docs/METRICS.md`). Refuse to start instead.
425        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        // The controller's handle sets, resolved here rather than at the
431        // controller's own construction below. Both claim series, and a
432        // duplicate must fail before any driver thread exists. Past that
433        // point a bare `?` would leak the threads instead of stopping them.
434        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        // The owner of the budget gauge. One series per pipeline; the
440        // drivers' `BackpressureMetrics` carry the pause series per thread.
441        let budget_metrics = InflightBudgetMetrics::try_new(&runtime_labels)
442            .map_err(|e| StartError::Metrics(e.to_string()))?;
443        // The owner of the I/O busy gauge, which this thread samples while it
444        // waits on the controller below.
445        let io_busy_metrics = IoRuntimeMetrics::try_new(&runtime_labels)
446            .map_err(|e| StartError::Metrics(e.to_string()))?;
447        // The owner of the source series. It publishes active lanes, and its
448        // clone goes to the source, which is the only thing that can measure
449        // lag. The per-thread instances are shadows of this one.
450        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        // The feed's handles, and those its backend resolves from its `Meter`,
460        // bind to the recorder installed above, so the feed is wrapped and
461        // attached after it.
462        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        // I/O runtime: sink workers (spawned by the caller-built SinkPool
495        // onto this runtime via its own handle), admin server, upkeep,
496        // signals, the feed's tasks. A caller-owned runtime
497        // (`with_io_runtime`) is adopted instead of built; either way this
498        // function shuts it down, after the source has dropped once the
499        // controller has started.
500        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        // Admin bind, upkeep, and the controller thread are started *after*
510        // the driver threads (below) so a failure in any of them can stop
511        // the already-running drivers instead of leaking them.
512
513        // The io tasks' handles live to the end of `run` and drop just before
514        // the io runtime shuts down, since dropping one aborts its task.
515        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        // Sink readiness: probe at startup and periodically (tighter while
529        // failing), driving the sinks-connected half of `/readyz`. No probe
530        // hook means nothing to check, so report connected.
531        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        // Wiring.
544        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        // Compact pinning: thread i on core i, low cores first, leaving the
559        // remaining cores for the I/O runtime and librdkafka's threads.
560        // Note for Kubernetes: exclusive cores require the kubelet static
561        // CPU manager with Guaranteed QoS and integer CPU requests;
562        // otherwise pinning only sets affinity within the shared cpuset.
563        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        // Short grace for cleanup-time driver stops (startup errors, a
580        // controller panic): the same budget the drain barrier uses.
581        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                // Each driver's backpressure series is its own (`driver-{i}`),
606                // so every thread owns what it publishes.
607                bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
608                    pipeline_name.clone(),
609                    format!("driver-{i}"),
610                    "driver",
611                )),
612                // A shadow. Every thread counts the records and polls it
613                // performed (those sum), but the source *gauges* (lag and
614                // active lanes) belong to the controller's instance below,
615                // which is the only one that sees the assignment and the
616                // only one the source itself is handed. A claiming
617                // constructor here would take the series hostage from it.
618                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 the drivers already spawned before bailing out.
646                    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        // Factories capture ShardQueues clones (their terminals need them),
659        // and the sink only drains once every queue clone is gone. Holding
660        // the factory through the drain would deadlock shutdown.
661        drop(self.chains);
662
663        // A cloned set of driver control senders kept by main, so it can stop
664        // the drivers itself if a later startup step fails or the controller
665        // thread dies (the originals are moved into the controller below).
666        let control_txs_for_stop = control_txs.clone();
667
668        // Admin bind now that the drivers are live: a bind failure (e.g. the
669        // address is taken) stops them instead of leaking them.
670        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        // Main: wait for the controller's choreography.
762        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        // Sampled here, off the I/O runtime, so the reading is neither
769        // delayed by a saturated worker nor counted as its busy time. The
770        // drain's `block_on` pauses it.
771        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            // Real time, since the busy time it is divided into is real.
776            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                    // Controller still working; keep waiting on the 100ms tick.
820                }
821                Err(_) => {
822                    // The controller thread ended without a Finished report
823                    // (a timeout with a finished handle, or the signal
824                    // channel disconnected), so it panicked. It never told
825                    // the drivers to stop and never set the shutdown flag, so
826                    // an untimed join here would wedge forever. Stop them
827                    // ourselves, drain the sink, and fail the run.
828                    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        // Drivers are already joined on the drain path; on the
850        // controller-died path make a best effort not to leak them.
851        for h in driver_handles {
852            let _ = h.join();
853        }
854        // A driver that panicked outside the batch guard is a bug worth
855        // failing the run over, even if the drain otherwise completed.
856        if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
857            state = ExitState::Failed(report);
858        }
859
860        // After the final commit. This handle keeps the feed alive for its
861        // close, whether or not the source has dropped its own.
862        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        // The controller drops the source on its way out, and a source's
873        // `Drop` may still need the io runtime (the S3 source aborts its lister
874        // there).
875        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
887/// The pipeline thread count for `cores` when `pipeline.threads` is unset:
888/// `cores` minus one core for every two I/O workers (rounded up) and one for
889/// the controller, at least 1.
890///
891/// Row encoding runs on the pipeline threads, and so does compression when the
892/// sink supplies a [`FrameCodec`](crate::sink::FrameCodec). The I/O workers
893/// carry the socket writes and shard bookkeeping. While `cores` is at least
894/// `ceil(io_threads / 2) + 2`, the runnable threads exceed the cores by
895/// `floor(io_threads / 2)` when every thread is busy, and the I/O workers
896/// share cores with pipeline threads. While `cores` is at least
897/// `ceil(io_threads / 2) + 2`, compact pinning leaves the
898/// `ceil(io_threads / 2) + 1` highest cores unpinned for the I/O runtime, the
899/// controller and the source's own threads.
900pub(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
904/// Publish an I/O busy sample when one is due, and warn on the sample that
905/// completes a saturated window.
906///
907/// `busy_total` is the I/O runtime's cumulative busy time over its
908/// `io_workers` async workers.
909pub(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
932/// Close `feed` and wait for it at most `within`. A close that fails or does
933/// not settle in time leaves the units the feed held to its redelivery.
934fn close_feed(io: &tokio::runtime::Runtime, feed: &dyn Feed, within: Duration) {
935    let Some(confirmation) = feed.close(within) else {
936        return;
937    };
938    // The timer is built inside `block_on`, where the runtime's reactor is.
939    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
955/// Set the shutdown flag, tell every driver thread to stop within a bounded
956/// drain barrier, and join them. Shared by the startup-error paths and the
957/// controller-death path so an early failure or a controller panic never
958/// leaves running pinned pipeline threads behind (`run` is a library API).
959///
960/// Joining bounds this. A driver observes the shutdown flag (abandoning any
961/// blocked batch) and the `Shutdown` control message (flushing within
962/// `grace`), then exits and drops its chain, closing the shard queues so the
963/// sink can drain afterwards.
964fn 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
985/// Install the exporter, degrading gracefully when a foreign recorder
986/// already owns the process. The pipeline keeps running against the
987/// existing recorder with a detached (empty-rendering) handle for the
988/// admin server. Shared by the runtime and the pipeline builder.
989pub(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/// The [`MetricsSettings`] a pipeline configuration maps to.
1008///
1009/// Assemblies that pre-register metric handles (sink shard metrics, custom
1010/// metrics) should call
1011/// [`metrics::install`](crate::metrics::install)`(&metrics_settings(&config))`
1012/// **before** constructing them; the runtime's own install then reuses the
1013/// exporter. Handles built before any install bind to the no-op recorder
1014/// and render nothing.
1015#[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
1030/// Probe the sinks at once and then periodically, keeping the
1031/// sinks-connected half of `/readyz` current. Runs until aborted.
1032///
1033/// A panic in `probe` marks the sinks not connected, so readiness does not
1034/// keep the last probe's answer once nothing updates it.
1035pub(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    /// A runtime samples the I/O busy ratio every 5 s over the sampler's
1099    /// 5-minute window unless a test overrides both.
1100    #[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}