1use 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#[derive(Clone, Debug)]
23pub struct RuntimeOptions {
24 pub handle_signals: bool,
27 pub max_records: usize,
29 pub poll_timeout: Duration,
31 pub idle_flush: Duration,
33 pub blocked_retry: Duration,
35 pub event_poll_timeout: Duration,
37 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#[derive(Clone, Debug)]
57pub struct ShutdownHandle(Arc<AtomicBool>);
58
59impl ShutdownHandle {
60 pub fn trigger(&self) {
62 self.0.store(true, Ordering::Relaxed);
63 }
64}
65
66#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum StartError {
70 #[error("invalid runtime configuration: {0}")]
72 Config(String),
73 #[error("metrics: {0}")]
75 Metrics(String),
76 #[error("io: {0}")]
78 Io(#[from] std::io::Error),
79}
80
81pub 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 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 #[must_use]
131 pub fn with_options(mut self, options: RuntimeOptions) -> Self {
132 self.options = options;
133 self
134 }
135
136 #[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 #[must_use]
152 pub fn shutdown_handle(&self) -> ShutdownHandle {
153 ShutdownHandle(Arc::clone(&self.shutdown))
154 }
155
156 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 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 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 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 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 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 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 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 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_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
331 return Err(StartError::Io(e));
332 }
333 }
334 }
335
336 drop(self.chains);
341
342 let control_txs_for_stop = control_txs.clone();
346
347 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 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 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 }
445 Err(_) => {
446 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 for h in driver_handles {
474 let _ = h.join();
475 }
476 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
494fn 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
523pub(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#[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}