Skip to main content

etl_core/pipeline/
runtime.rs

1//! Process assembly: threads, runtimes, observability, and the run loop.
2
3use super::controller::{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::config::{MetricsExporter, PinningMode, PipelineConfig};
10use crate::metrics::{
11    self, BackpressureMetrics, CheckpointMetrics, ComponentLabels, E2eBasis, Exporter,
12    MetricsHandle, MetricsSettings, PipelineMetrics, PipelineState, SourceMetrics,
13};
14use crate::ops::RunnableChain;
15use crate::source::{DrainBarrier, Source};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::{Duration, Instant};
19
20/// Knobs that are not part of the user-facing YAML (loop granularities,
21/// test hooks). The defaults suit production; tests shrink the timings.
22#[derive(Clone, Debug)]
23pub struct RuntimeOptions {
24    /// Install SIGTERM/SIGINT handlers that trigger a graceful drain.
25    /// Disable in tests and drive [`ShutdownHandle`] instead.
26    pub handle_signals: bool,
27    /// Max payloads per lane poll.
28    pub max_records: usize,
29    /// Lane poll timeout (also the paused/idle loop sleep).
30    pub poll_timeout: Duration,
31    /// Flush the chain after this long without new data.
32    pub idle_flush: Duration,
33    /// Sleep between retries of a blocked batch.
34    pub blocked_retry: Duration,
35    /// Controller `poll_events` timeout.
36    pub event_poll_timeout: Duration,
37    /// Version string published on `etl_pipeline_info`.
38    pub version: String,
39}
40
41impl Default for RuntimeOptions {
42    fn default() -> Self {
43        RuntimeOptions {
44            handle_signals: true,
45            max_records: 512,
46            poll_timeout: Duration::from_millis(10),
47            idle_flush: Duration::from_millis(100),
48            blocked_retry: Duration::from_millis(2),
49            event_poll_timeout: Duration::from_millis(50),
50            version: env!("CARGO_PKG_VERSION").to_string(),
51        }
52    }
53}
54
55/// Triggers a graceful drain from anywhere (tests, custom signal wiring).
56#[derive(Clone, Debug)]
57pub struct ShutdownHandle(Arc<AtomicBool>);
58
59impl ShutdownHandle {
60    /// Begin the drain. Idempotent.
61    pub fn trigger(&self) {
62        self.0.store(true, Ordering::Relaxed);
63    }
64}
65
66/// The pipeline could not start.
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum StartError {
70    /// Invalid effective configuration.
71    #[error("invalid runtime configuration: {0}")]
72    Config(String),
73    /// The metrics exporter could not be installed.
74    #[error("metrics: {0}")]
75    Metrics(String),
76    /// The I/O runtime or the admin server could not start.
77    #[error("io: {0}")]
78    Io(#[from] std::io::Error),
79}
80
81/// One pipeline process: source, per-thread chains, and a sink, assembled
82/// per `docs/DESIGN.md` § Process anatomy.
83///
84/// The caller creates the shared [`InflightBudget`] first and wires it into
85/// the chain terminals (which `add` on enqueue) and the sink workers (which
86/// `sub` on durable write or abandonment) before handing everything here.
87pub struct PipelineRuntime<S: Source> {
88    config: PipelineConfig,
89    source: S,
90    chains: Box<dyn FnMut(usize) -> Box<dyn RunnableChain> + Send>,
91    sink: SinkRuntime,
92    budget: Arc<InflightBudget>,
93    shutdown: Arc<AtomicBool>,
94    options: RuntimeOptions,
95    io: Option<tokio::runtime::Runtime>,
96}
97
98impl<S: Source> std::fmt::Debug for PipelineRuntime<S> {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("PipelineRuntime")
101            .field("pipeline", &self.config.pipeline.name)
102            .field("options", &self.options)
103            .finish_non_exhaustive()
104    }
105}
106
107impl<S: Source + 'static> PipelineRuntime<S> {
108    /// Assemble a runtime. `chains` builds one erased chain per pipeline
109    /// thread (thread index in).
110    pub fn new(
111        config: PipelineConfig,
112        source: S,
113        chains: impl FnMut(usize) -> Box<dyn RunnableChain> + Send + 'static,
114        sink: SinkRuntime,
115        budget: Arc<InflightBudget>,
116    ) -> Self {
117        PipelineRuntime {
118            config,
119            source,
120            chains: Box::new(chains),
121            sink,
122            budget,
123            shutdown: Arc::new(AtomicBool::new(false)),
124            options: RuntimeOptions::default(),
125            io: None,
126        }
127    }
128
129    /// Override runtime options (returns `self` for chaining).
130    #[must_use]
131    pub fn with_options(mut self, options: RuntimeOptions) -> Self {
132        self.options = options;
133        self
134    }
135
136    /// Use a caller-owned tokio runtime as the I/O runtime instead of
137    /// building one inside [`run`](Self::run) — for assemblies whose
138    /// connectors needed a handle before the runtime existed (sink workers
139    /// spawned at construction, schema-registry fetchers, async pre-flight
140    /// validation). `run` shuts it down on exit exactly as it does the
141    /// internally built one; connector tasks spawned on it earlier keep
142    /// running until then. Without this, assemblies end up running a
143    /// second runtime, doubling `pipeline.io_threads`.
144    #[must_use]
145    pub fn with_io_runtime(mut self, io: tokio::runtime::Runtime) -> Self {
146        self.io = Some(io);
147        self
148    }
149
150    /// A handle that triggers a graceful drain.
151    #[must_use]
152    pub fn shutdown_handle(&self) -> ShutdownHandle {
153        ShutdownHandle(Arc::clone(&self.shutdown))
154    }
155
156    /// Effective pipeline thread count: the config override, else
157    /// `available_parallelism` minus the I/O reserve (I/O workers + the
158    /// controller), at least 1. `available_parallelism` respects cgroup
159    /// CPU quotas, so Kubernetes limits size this correctly; pods without
160    /// limits see the node's cores — set `pipeline.threads` explicitly
161    /// there.
162    fn thread_count(&self) -> usize {
163        self.config.pipeline.threads.unwrap_or_else(|| {
164            let cores = std::thread::available_parallelism().map_or(2, usize::from);
165            cores
166                .saturating_sub(self.config.pipeline.io_threads + 1)
167                .max(1)
168        })
169    }
170
171    /// Run the pipeline to completion (blocking). Returns when the
172    /// pipeline drained after a shutdown trigger/signal or failed.
173    pub fn run(mut self) -> Result<ExitReport, StartError> {
174        let threads = self.thread_count();
175        if threads == 0 || self.config.pipeline.io_threads == 0 {
176            return Err(StartError::Config("thread counts must be non-zero".into()));
177        }
178        let pipeline_name = self.config.pipeline.name.clone();
179
180        // Observability first: everything after this records metrics.
181        let handle = install_or_reuse(&metrics_settings(&self.config))?;
182
183        let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
184        let pipeline_metrics = PipelineMetrics::new(&runtime_labels, &self.options.version);
185        pipeline_metrics.set_state(PipelineState::Starting);
186        pipeline_metrics.set_threads(threads);
187
188        let health = HealthState::new(threads, HealthThresholds::default());
189
190        // I/O runtime: sink workers (spawned by the caller-built SinkPool
191        // onto this runtime via its own handle), admin server, upkeep,
192        // signals. A caller-owned runtime (`with_io_runtime`) is adopted
193        // instead of built; either way this function owns its shutdown.
194        let io = match self.io.take() {
195            Some(io) => io,
196            None => tokio::runtime::Builder::new_multi_thread()
197                .worker_threads(self.config.pipeline.io_threads)
198                .thread_name("etl-io")
199                .enable_all()
200                .build()?,
201        };
202
203        // Admin bind, upkeep, and the controller thread are started *after*
204        // the driver threads (below) so a failure in any of them can stop
205        // the already-running drivers instead of leaking them.
206
207        if self.options.handle_signals {
208            let shutdown = Arc::clone(&self.shutdown);
209            io.spawn(async move {
210                wait_for_signal().await;
211                tracing::info!("shutdown signal received; draining");
212                shutdown.store(true, Ordering::Relaxed);
213            });
214        }
215
216        // Sink readiness: probe at startup and periodically (tighter while
217        // failing), driving the sinks-connected half of `/readyz`. No probe
218        // hook means nothing to check — report connected.
219        match self.sink.probe.take() {
220            Some(probe) => {
221                let health_probe = Arc::clone(&health);
222                io.spawn(async move {
223                    loop {
224                        let connected = match probe().await {
225                            Ok(()) => true,
226                            Err(e) => {
227                                tracing::warn!(error = %e, "sink probe failed");
228                                false
229                            }
230                        };
231                        health_probe.set_sinks_connected(connected);
232                        let recheck = if connected {
233                            Duration::from_secs(30)
234                        } else {
235                            Duration::from_secs(5)
236                        };
237                        tokio::time::sleep(recheck).await;
238                    }
239                });
240            }
241            None => health.set_sinks_connected(true),
242        }
243
244        // Wiring.
245        let (events_tx, events_rx) = crossbeam_channel::unbounded::<DriverEvent>();
246        let (to_main_tx, to_main_rx) = crossbeam_channel::unbounded::<ControllerSignal>();
247        let (sink_drained_tx, sink_drained_rx) = crossbeam_channel::unbounded::<()>();
248        let checkpointer = Checkpointer::new();
249
250        let bp_params = BackpressureParams::from_budget(
251            usize::try_from(self.config.backpressure.max_inflight_bytes.as_u64())
252                .unwrap_or(usize::MAX),
253            self.config.backpressure.high_ratio,
254            self.config.backpressure.low_ratio,
255            self.config.backpressure.min_pause,
256        );
257
258        // Compact pinning: thread i on core i, low cores first, leaving the
259        // remaining cores for the I/O runtime and librdkafka's threads.
260        // Note for Kubernetes: exclusive cores require the kubelet static
261        // CPU manager with Guaranteed QoS and integer CPU requests;
262        // otherwise pinning only sets affinity within the shared cpuset.
263        let core_ids: Vec<Option<core_affinity::CoreId>> =
264            if self.config.pipeline.pinning == PinningMode::Compact {
265                let mut ids = core_affinity::get_core_ids().unwrap_or_default();
266                ids.sort_by_key(|c| c.id);
267                if ids.len() < threads {
268                    tracing::warn!(
269                        cores = ids.len(),
270                        threads,
271                        "fewer cores than pipeline threads; surplus threads run unpinned"
272                    );
273                }
274                (0..threads).map(|i| ids.get(i).copied()).collect()
275            } else {
276                vec![None; threads]
277            };
278
279        // Short grace for cleanup-time driver stops (startup errors, a
280        // controller panic): the same budget the drain barrier uses.
281        let drain_timeout = self.config.checkpoint.drain_timeout;
282
283        let mut control_txs = Vec::with_capacity(threads);
284        let mut driver_handles = Vec::with_capacity(threads);
285        for i in 0..threads {
286            let (control_tx, control_rx) = crossbeam_channel::unbounded::<ThreadControl<S::Lane>>();
287            control_txs.push(control_tx);
288            let ctx = DriverContext {
289                params: DriverParams {
290                    thread: i,
291                    max_records: self.options.max_records,
292                    poll_timeout: self.options.poll_timeout,
293                    idle_flush: self.options.idle_flush,
294                    blocked_retry: self.options.blocked_retry,
295                    queue_low_ratio: self.config.backpressure.low_ratio,
296                },
297                control: control_rx,
298                events: events_tx.clone(),
299                chain: (self.chains)(i),
300                bp: WatermarkController::new(bp_params),
301                budget: Arc::clone(&self.budget),
302                queues: self.sink.queues.clone(),
303                health: Arc::clone(&health),
304                bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
305                    pipeline_name.clone(),
306                    format!("driver-{i}"),
307                    "driver",
308                )),
309                source_metrics: SourceMetrics::new(
310                    &ComponentLabels::new(pipeline_name.clone(), "source", "source"),
311                    self.config.metrics.per_partition_detail,
312                ),
313                shutdown: Arc::clone(&self.shutdown),
314            };
315            let core = core_ids.get(i).copied().flatten();
316            let spawned = std::thread::Builder::new()
317                .name(format!("etl-pipeline-{i}"))
318                .spawn(move || {
319                    if let Some(core) = core
320                        && !core_affinity::set_for_current(core)
321                    {
322                        tracing::warn!(core = core.id, "failed to pin pipeline thread");
323                    }
324                    run_driver(ctx)
325                });
326            match spawned {
327                Ok(handle) => driver_handles.push(handle),
328                Err(e) => {
329                    // Stop the drivers already spawned before bailing out.
330                    stop_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
331                    return Err(StartError::Io(e));
332                }
333            }
334        }
335
336        // The chain factory has served its purpose. Factories naturally
337        // capture ShardQueues clones (their terminals need them), and the
338        // sink only drains once every queue clone is gone — holding the
339        // factory through the drain would deadlock shutdown.
340        drop(self.chains);
341
342        // A cloned set of driver control senders kept by main, so it can stop
343        // the drivers itself if a later startup step fails or the controller
344        // thread dies (the originals are moved into the controller below).
345        let control_txs_for_stop = control_txs.clone();
346
347        // Admin bind now that the drivers are live: a bind failure (e.g. the
348        // metrics port is taken) stops them instead of leaking them.
349        let admin = match io.block_on(AdminServer::bind(
350            self.config.metrics.listen,
351            handle.render_fn(),
352            Arc::clone(&health),
353        )) {
354            Ok(admin) => admin,
355            Err(e) => {
356                stop_drivers(
357                    &self.shutdown,
358                    &control_txs_for_stop,
359                    driver_handles,
360                    drain_timeout,
361                );
362                return Err(StartError::Io(e));
363            }
364        };
365        let (admin_stop_tx, admin_stop_rx) = tokio::sync::watch::channel(false);
366        io.spawn(admin.run(admin_stop_rx));
367        {
368            // spawn_upkeep uses tokio::spawn internally; enter the runtime.
369            let _guard = io.enter();
370            let _upkeep = handle.spawn_upkeep(Duration::from_secs(5));
371        }
372
373        let controller_ctx = ControllerContext {
374            source: self.source,
375            checkpointer,
376            control_txs,
377            events_rx,
378            to_main: to_main_tx,
379            sink_drained_rx,
380            shutdown: Arc::clone(&self.shutdown),
381            health: Arc::clone(&health),
382            commit_interval: self.config.checkpoint.interval,
383            drain_timeout: self.config.checkpoint.drain_timeout,
384            event_poll_timeout: self.options.event_poll_timeout,
385            max_pending_batches: self.config.checkpoint.max_pending_batches,
386            stalled_fail_after: self.config.checkpoint.stalled_fail_after,
387            checkpoint_metrics: CheckpointMetrics::new(
388                &ComponentLabels::new(pipeline_name.clone(), "checkpoint", "checkpoint"),
389                self.config.metrics.per_partition_detail,
390            ),
391            source_metrics: SourceMetrics::new(
392                &ComponentLabels::new(pipeline_name.clone(), "source", "source"),
393                self.config.metrics.per_partition_detail,
394            ),
395            pipeline_metrics,
396        };
397        let controller_handle = match std::thread::Builder::new()
398            .name("etl-controller".into())
399            .spawn(move || run_controller(controller_ctx))
400        {
401            Ok(handle) => handle,
402            Err(e) => {
403                stop_drivers(
404                    &self.shutdown,
405                    &control_txs_for_stop,
406                    driver_handles,
407                    drain_timeout,
408                );
409                return Err(StartError::Io(e));
410            }
411        };
412
413        // Main: wait for the controller's choreography.
414        let mut sink_drain = None;
415        let mut driver_panic: Option<FatalErrorReport> = None;
416        let sink_runtime = self.sink;
417        let mut drain_fn = Some(sink_runtime.drain);
418        drop(sink_runtime.queues);
419
420        let (mut state, final_watermarks) = loop {
421            match to_main_rx.recv_timeout(Duration::from_millis(100)) {
422                Ok(ControllerSignal::LanesDrained { sink_deadline }) => {
423                    for (i, h) in driver_handles.drain(..).enumerate() {
424                        if h.join().is_err() {
425                            driver_panic.get_or_insert(FatalErrorReport {
426                                component: format!("driver-{i}"),
427                                reason: "pipeline thread panicked outside the batch guard".into(),
428                            });
429                        }
430                    }
431                    if let Some(drain) = drain_fn.take() {
432                        let budget = sink_deadline.saturating_duration_since(Instant::now());
433                        sink_drain = Some(io.block_on(drain(budget)));
434                    }
435                    let _ = sink_drained_tx.send(());
436                }
437                Ok(ControllerSignal::Finished(report)) => {
438                    break (report.state, report.final_watermarks);
439                }
440                Err(crossbeam_channel::RecvTimeoutError::Timeout)
441                    if !controller_handle.is_finished() =>
442                {
443                    // Controller still working; keep waiting on the 100ms tick.
444                }
445                Err(_) => {
446                    // The controller thread ended without a Finished report
447                    // (a timeout with a finished handle, or the signal
448                    // channel disconnected): it panicked. It never told the
449                    // drivers to stop and never set the shutdown flag, so an
450                    // untimed join here would wedge forever — stop them
451                    // ourselves, drain the sink, and fail the run.
452                    stop_drivers(
453                        &self.shutdown,
454                        &control_txs_for_stop,
455                        std::mem::take(&mut driver_handles),
456                        drain_timeout,
457                    );
458                    if let Some(drain) = drain_fn.take() {
459                        sink_drain = Some(io.block_on(drain(drain_timeout)));
460                    }
461                    break (
462                        ExitState::Failed(FatalErrorReport {
463                            component: "controller".into(),
464                            reason: "controller thread panicked".into(),
465                        }),
466                        Vec::new(),
467                    );
468                }
469            }
470        };
471        // Drivers are already joined on the drain path; on the
472        // controller-died path make a best effort not to leak them.
473        for h in driver_handles {
474            let _ = h.join();
475        }
476        // A driver that panicked outside the batch guard is a bug worth
477        // failing the run over, even if the drain otherwise completed.
478        if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
479            state = ExitState::Failed(report);
480        }
481
482        let _ = admin_stop_tx.send(true);
483        io.shutdown_timeout(Duration::from_secs(2));
484        let _ = controller_handle.join();
485
486        Ok(ExitReport {
487            state,
488            sink_drain,
489            final_watermarks,
490        })
491    }
492}
493
494/// Set the shutdown flag, tell every driver thread to stop within a bounded
495/// drain barrier, and join them. Shared by the startup-error paths and the
496/// controller-death path so an early failure or a controller panic never
497/// leaves running pinned pipeline threads behind (`run` is a library API).
498///
499/// Joining is what actually bounds this — a driver observes the shutdown
500/// flag (abandoning any blocked batch) and the `Shutdown` control message
501/// (flushing within `grace`), then exits and drops its chain, closing the
502/// shard queues so the sink can drain afterwards.
503fn stop_drivers<L>(
504    shutdown: &AtomicBool,
505    control_txs: &[crossbeam_channel::Sender<ThreadControl<L>>],
506    driver_handles: Vec<std::thread::JoinHandle<DriverExit>>,
507    grace: Duration,
508) {
509    shutdown.store(true, Ordering::Relaxed);
510    let deadline = Instant::now() + grace;
511    let barrier = DrainBarrier::new(control_txs.len());
512    for tx in control_txs {
513        let _ = tx.send(ThreadControl::Shutdown {
514            barrier: barrier.clone(),
515            deadline,
516        });
517    }
518    for handle in driver_handles {
519        let _ = handle.join();
520    }
521}
522
523/// Install the exporter, degrading gracefully when a foreign recorder
524/// already owns the process: the pipeline keeps running against the
525/// existing recorder with a detached (empty-rendering) handle for the
526/// admin server. Shared by the runtime and the pipeline builder.
527pub(crate) fn install_or_reuse(settings: &MetricsSettings) -> Result<MetricsHandle, StartError> {
528    match metrics::install(settings) {
529        Ok(h) => Ok(h),
530        Err(metrics::MetricsError::AlreadyInstalled) => {
531            tracing::warn!(
532                "a metrics recorder is already installed; continuing \
533                 with the existing one and a detached render handle"
534            );
535            metrics::install(&MetricsSettings {
536                exporter: Exporter::None,
537                ..settings.clone()
538            })
539            .map_err(|e| StartError::Metrics(e.to_string()))
540        }
541        Err(e) => Err(StartError::Metrics(e.to_string())),
542    }
543}
544
545/// The [`MetricsSettings`] a pipeline configuration maps to.
546///
547/// Assemblies that pre-register metric handles (sink shard metrics, custom
548/// metrics) should call
549/// [`metrics::install`](crate::metrics::install)`(&metrics_settings(&config))`
550/// **before** constructing them; the runtime's own install then reuses the
551/// exporter. Handles built before any install bind to the no-op recorder
552/// and render nothing.
553#[must_use]
554pub fn metrics_settings(config: &PipelineConfig) -> MetricsSettings {
555    MetricsSettings {
556        exporter: match config.metrics.exporter {
557            MetricsExporter::Prometheus => Exporter::Prometheus,
558            MetricsExporter::None => Exporter::None,
559        },
560        listen: config.metrics.listen,
561        per_partition_detail: config.metrics.per_partition_detail,
562        e2e_basis: match config.metrics.e2e_basis {
563            crate::config::E2eBasis::Ingest => E2eBasis::Ingest,
564            crate::config::E2eBasis::Event => E2eBasis::Event,
565        },
566    }
567}
568
569async fn wait_for_signal() {
570    #[cfg(unix)]
571    {
572        let mut term =
573            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
574                Ok(s) => s,
575                Err(e) => {
576                    tracing::error!(error = %e, "failed to install SIGTERM handler");
577                    std::future::pending::<()>().await;
578                    return;
579                }
580            };
581        tokio::select! {
582            _ = term.recv() => {}
583            r = tokio::signal::ctrl_c() => {
584                if let Err(e) = r {
585                    tracing::error!(error = %e, "ctrl_c handler failed");
586                    std::future::pending::<()>().await;
587                }
588            }
589        }
590    }
591    #[cfg(not(unix))]
592    {
593        if let Err(e) = tokio::signal::ctrl_c().await {
594            tracing::error!(error = %e, "ctrl_c handler failed");
595            std::future::pending::<()>().await;
596        }
597    }
598}