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, Meter,
12 MetricRole, MetricsHandle, MetricsSettings, PipelineMetrics, PipelineState, SourceMetrics,
13};
14use crate::ops::RunnableChain;
15use crate::source::{DrainBarrier, Source};
16use std::net::SocketAddr;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::time::{Duration, Instant};
20
21#[derive(Clone, Debug)]
24pub struct RuntimeOptions {
25 pub handle_signals: bool,
28 pub max_records: usize,
30 pub poll_timeout: Duration,
32 pub idle_flush: Duration,
34 pub blocked_retry: Duration,
36 pub event_poll_timeout: Duration,
38 pub version: String,
40}
41
42impl Default for RuntimeOptions {
43 fn default() -> Self {
44 RuntimeOptions {
45 handle_signals: true,
46 max_records: 512,
47 poll_timeout: Duration::from_millis(10),
48 idle_flush: Duration::from_millis(100),
49 blocked_retry: Duration::from_millis(2),
50 event_poll_timeout: Duration::from_millis(50),
51 version: env!("CARGO_PKG_VERSION").to_string(),
52 }
53 }
54}
55
56#[derive(Clone, Debug)]
58pub struct ShutdownHandle(Arc<AtomicBool>);
59
60impl ShutdownHandle {
61 pub fn trigger(&self) {
63 self.0.store(true, Ordering::Relaxed);
64 }
65}
66
67#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum StartError {
71 #[error("invalid runtime configuration: {0}")]
73 Config(String),
74 #[error("metrics: {0}")]
76 Metrics(String),
77 #[error("admin.listen: cannot bind {addr}: {source}")]
80 AdminBind {
81 addr: SocketAddr,
83 source: std::io::Error,
85 },
86 #[error("io: {0}")]
88 Io(#[from] std::io::Error),
89}
90
91pub struct PipelineRuntime<S: Source> {
97 config: PipelineConfig,
98 source: S,
99 chains: Box<dyn FnMut(usize) -> Box<dyn RunnableChain> + Send>,
100 sink: SinkRuntime,
101 budget: Arc<InflightBudget>,
102 shutdown: Arc<AtomicBool>,
103 options: RuntimeOptions,
104 io: Option<tokio::runtime::Runtime>,
105}
106
107impl<S: Source> std::fmt::Debug for PipelineRuntime<S> {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("PipelineRuntime")
110 .field("pipeline", &self.config.pipeline.name)
111 .field("options", &self.options)
112 .finish_non_exhaustive()
113 }
114}
115
116impl<S: Source + 'static> PipelineRuntime<S> {
117 pub fn new(
120 config: PipelineConfig,
121 source: S,
122 chains: impl FnMut(usize) -> Box<dyn RunnableChain> + Send + 'static,
123 sink: SinkRuntime,
124 budget: Arc<InflightBudget>,
125 ) -> Self {
126 PipelineRuntime {
127 config,
128 source,
129 chains: Box::new(chains),
130 sink,
131 budget,
132 shutdown: Arc::new(AtomicBool::new(false)),
133 options: RuntimeOptions::default(),
134 io: None,
135 }
136 }
137
138 #[must_use]
140 pub fn with_options(mut self, options: RuntimeOptions) -> Self {
141 self.options = options;
142 self
143 }
144
145 #[must_use]
154 pub fn with_io_runtime(mut self, io: tokio::runtime::Runtime) -> Self {
155 self.io = Some(io);
156 self
157 }
158
159 #[must_use]
161 pub fn shutdown_handle(&self) -> ShutdownHandle {
162 ShutdownHandle(Arc::clone(&self.shutdown))
163 }
164
165 fn thread_count(&self) -> usize {
171 self.config.pipeline.threads.unwrap_or_else(|| {
172 let cores = std::thread::available_parallelism().map_or(2, usize::from);
173 cores
174 .saturating_sub(self.config.pipeline.io_threads + 1)
175 .max(1)
176 })
177 }
178
179 pub fn run(mut self) -> Result<ExitReport, StartError> {
182 let threads = self.thread_count();
183 if threads == 0 || self.config.pipeline.io_threads == 0 {
184 return Err(StartError::Config("thread counts must be non-zero".into()));
185 }
186 let pipeline_name = self.config.pipeline.name.clone();
187
188 let source_ct = self.source.component_type().to_string();
192 let source_meter = Meter::for_component(
193 &source_ct,
194 MetricRole::Source,
195 pipeline_name.clone(),
196 "source",
197 );
198
199 let handle = install_or_reuse(&metrics_settings(&self.config))?;
201
202 let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
203 let pipeline_metrics = PipelineMetrics::try_new(&runtime_labels, &self.options.version)
207 .map_err(|e| StartError::Metrics(e.to_string()))?;
208 pipeline_metrics.set_state(PipelineState::Starting);
209 pipeline_metrics.set_threads(threads);
210
211 let checkpoint_metrics = CheckpointMetrics::try_new(
216 &ComponentLabels::new(pipeline_name.clone(), "checkpoint", "checkpoint"),
217 self.config.metrics.per_partition_detail,
218 )
219 .map_err(|e| StartError::Metrics(e.to_string()))?;
220 let controller_source_metrics = Arc::new(
224 SourceMetrics::try_new(&ComponentLabels::new(
225 pipeline_name.clone(),
226 "source",
227 source_ct.clone(),
228 ))
229 .map_err(|e| StartError::Metrics(e.to_string()))?,
230 );
231
232 let health = HealthState::new(threads, HealthThresholds::default());
233
234 let io = match self.io.take() {
239 Some(io) => io,
240 None => tokio::runtime::Builder::new_multi_thread()
241 .worker_threads(self.config.pipeline.io_threads)
242 .thread_name("spate-io")
243 .enable_all()
244 .build()?,
245 };
246
247 if self.options.handle_signals {
252 let shutdown = Arc::clone(&self.shutdown);
253 io.spawn(async move {
254 wait_for_signal().await;
255 tracing::info!("shutdown signal received; draining");
256 shutdown.store(true, Ordering::Relaxed);
257 });
258 }
259
260 match self.sink.probe.take() {
264 Some(probe) => {
265 let health_probe = Arc::clone(&health);
266 io.spawn(async move {
267 loop {
268 let connected = match probe().await {
269 Ok(()) => true,
270 Err(e) => {
271 tracing::warn!(error = %e, "sink probe failed");
272 false
273 }
274 };
275 health_probe.set_sinks_connected(connected);
276 let recheck = if connected {
277 Duration::from_secs(30)
278 } else {
279 Duration::from_secs(5)
280 };
281 tokio::time::sleep(recheck).await;
282 }
283 });
284 }
285 None => health.set_sinks_connected(true),
286 }
287
288 let (events_tx, events_rx) = crossbeam_channel::unbounded::<DriverEvent>();
290 let (to_main_tx, to_main_rx) = crossbeam_channel::unbounded::<ControllerSignal>();
291 let (sink_drained_tx, sink_drained_rx) = crossbeam_channel::unbounded::<()>();
292 let checkpointer = Checkpointer::new();
293
294 let bp_params = BackpressureParams::from_budget(
295 usize::try_from(self.config.backpressure.max_inflight_bytes.as_u64())
296 .unwrap_or(usize::MAX),
297 self.config.backpressure.high_ratio,
298 self.config.backpressure.low_ratio,
299 self.config.backpressure.min_pause,
300 );
301
302 let core_ids: Vec<Option<core_affinity::CoreId>> =
308 if self.config.pipeline.pinning == PinningMode::Compact {
309 let mut ids = core_affinity::get_core_ids().unwrap_or_default();
310 ids.sort_by_key(|c| c.id);
311 if ids.len() < threads {
312 tracing::warn!(
313 cores = ids.len(),
314 threads,
315 "fewer cores than pipeline threads; surplus threads run unpinned"
316 );
317 }
318 (0..threads).map(|i| ids.get(i).copied()).collect()
319 } else {
320 vec![None; threads]
321 };
322
323 let drain_timeout = self.config.checkpoint.drain_timeout;
326
327 let mut control_txs = Vec::with_capacity(threads);
328 let mut driver_handles = Vec::with_capacity(threads);
329 for i in 0..threads {
330 let (control_tx, control_rx) = crossbeam_channel::unbounded::<ThreadControl<S::Lane>>();
331 control_txs.push(control_tx);
332 let ctx = DriverContext {
333 params: DriverParams {
334 thread: i,
335 max_records: self.options.max_records,
336 poll_timeout: self.options.poll_timeout,
337 idle_flush: self.options.idle_flush,
338 blocked_retry: self.options.blocked_retry,
339 queue_low_ratio: self.config.backpressure.low_ratio,
340 max_pending_batches: self.config.checkpoint.max_pending_batches,
341 },
342 control: control_rx,
343 events: events_tx.clone(),
344 chain: (self.chains)(i),
345 bp: WatermarkController::new(bp_params),
346 budget: Arc::clone(&self.budget),
347 queues: self.sink.queues.clone(),
348 health: Arc::clone(&health),
349 bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
352 pipeline_name.clone(),
353 format!("driver-{i}"),
354 "driver",
355 )),
356 source_metrics: SourceMetrics::shadow(&ComponentLabels::new(
363 pipeline_name.clone(),
364 "source",
365 source_ct.clone(),
366 )),
367 shutdown: Arc::clone(&self.shutdown),
368 };
369 let core = core_ids.get(i).copied().flatten();
370 let spawned = std::thread::Builder::new()
371 .name(format!("spate-pipeline-{i}"))
372 .spawn(move || {
373 if let Some(core) = core
374 && !core_affinity::set_for_current(core)
375 {
376 tracing::warn!(core = core.id, "failed to pin pipeline thread");
377 }
378 run_driver(ctx)
379 });
380 match spawned {
381 Ok(handle) => driver_handles.push(handle),
382 Err(e) => {
383 stop_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
385 return Err(StartError::Io(e));
386 }
387 }
388 }
389
390 drop(self.chains);
394
395 let control_txs_for_stop = control_txs.clone();
399
400 let admin_stop_tx = match self.config.admin.listen {
403 Some(addr) => {
404 let render = handle.exports().then(|| handle.render_fn());
405 let admin = match io.block_on(AdminServer::bind(addr, render, Arc::clone(&health)))
406 {
407 Ok(admin) => admin,
408 Err(source) => {
409 stop_drivers(
410 &self.shutdown,
411 &control_txs_for_stop,
412 driver_handles,
413 drain_timeout,
414 );
415 return Err(StartError::AdminBind { addr, source });
416 }
417 };
418 let (admin_stop_tx, admin_stop_rx) = tokio::sync::watch::channel(false);
419 io.spawn(admin.run(admin_stop_rx));
420 Some(admin_stop_tx)
421 }
422 None => {
423 if handle.exports() {
424 tracing::warn!(
425 "an exporter is installed but `admin.listen` is `none`, so \
426 nothing serves /metrics; render the exposition through \
427 Pipeline::metrics(), or set `metrics.exporter: none`"
428 );
429 }
430 None
431 }
432 };
433 {
434 let _guard = io.enter();
436 let _upkeep = handle.spawn_upkeep(Duration::from_secs(5));
437 }
438
439 let controller_ctx = ControllerContext {
440 source: self.source,
441 checkpointer,
442 control_txs,
443 events_rx,
444 to_main: to_main_tx,
445 sink_drained_rx,
446 shutdown: Arc::clone(&self.shutdown),
447 health: Arc::clone(&health),
448 commit_interval: self.config.checkpoint.interval,
449 drain_timeout: self.config.checkpoint.drain_timeout,
450 event_poll_timeout: self.options.event_poll_timeout,
451 stalled_fail_after: self.config.checkpoint.stalled_fail_after,
452 checkpoint_metrics,
453 source_metrics: controller_source_metrics,
454 source_meter,
455 per_partition_detail: self.config.metrics.per_partition_detail,
456 pipeline_metrics,
457 };
458 let controller_handle = match std::thread::Builder::new()
459 .name("spate-controller".into())
460 .spawn(move || run_controller(controller_ctx))
461 {
462 Ok(handle) => handle,
463 Err(e) => {
464 stop_drivers(
465 &self.shutdown,
466 &control_txs_for_stop,
467 driver_handles,
468 drain_timeout,
469 );
470 return Err(StartError::Io(e));
471 }
472 };
473
474 let mut sink_drain = None;
476 let mut driver_panic: Option<FatalErrorReport> = None;
477 let sink_runtime = self.sink;
478 let mut drain_fn = Some(sink_runtime.drain);
479 drop(sink_runtime.queues);
480
481 let (mut state, final_watermarks) = loop {
482 match to_main_rx.recv_timeout(Duration::from_millis(100)) {
483 Ok(ControllerSignal::LanesDrained { sink_deadline }) => {
484 for (i, h) in driver_handles.drain(..).enumerate() {
485 if h.join().is_err() {
486 driver_panic.get_or_insert(FatalErrorReport {
487 component: format!("driver-{i}"),
488 reason: "pipeline thread panicked outside the batch guard".into(),
489 });
490 }
491 }
492 if let Some(drain) = drain_fn.take() {
493 let budget = sink_deadline.saturating_duration_since(Instant::now());
494 sink_drain = Some(io.block_on(drain(budget)));
495 }
496 let _ = sink_drained_tx.send(());
497 }
498 Ok(ControllerSignal::Finished(report)) => {
499 break (report.state, report.final_watermarks);
500 }
501 Err(crossbeam_channel::RecvTimeoutError::Timeout)
502 if !controller_handle.is_finished() =>
503 {
504 }
506 Err(_) => {
507 stop_drivers(
514 &self.shutdown,
515 &control_txs_for_stop,
516 std::mem::take(&mut driver_handles),
517 drain_timeout,
518 );
519 if let Some(drain) = drain_fn.take() {
520 sink_drain = Some(io.block_on(drain(drain_timeout)));
521 }
522 break (
523 ExitState::Failed(FatalErrorReport {
524 component: "controller".into(),
525 reason: "controller thread panicked".into(),
526 }),
527 Vec::new(),
528 );
529 }
530 }
531 };
532 for h in driver_handles {
535 let _ = h.join();
536 }
537 if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
540 state = ExitState::Failed(report);
541 }
542
543 if let Some(tx) = &admin_stop_tx {
544 let _ = tx.send(true);
545 }
546 io.shutdown_timeout(Duration::from_secs(2));
547 let _ = controller_handle.join();
548
549 Ok(ExitReport {
550 state,
551 sink_drain,
552 final_watermarks,
553 })
554 }
555}
556
557fn stop_drivers<L>(
567 shutdown: &AtomicBool,
568 control_txs: &[crossbeam_channel::Sender<ThreadControl<L>>],
569 driver_handles: Vec<std::thread::JoinHandle<DriverExit>>,
570 grace: Duration,
571) {
572 shutdown.store(true, Ordering::Relaxed);
573 let deadline = Instant::now() + grace;
574 let barrier = DrainBarrier::new(control_txs.len());
575 for tx in control_txs {
576 let _ = tx.send(ThreadControl::Shutdown {
577 barrier: barrier.clone(),
578 deadline,
579 });
580 }
581 for handle in driver_handles {
582 let _ = handle.join();
583 }
584}
585
586pub(crate) fn install_or_reuse(settings: &MetricsSettings) -> Result<MetricsHandle, StartError> {
591 match metrics::install(settings) {
592 Ok(h) => Ok(h),
593 Err(metrics::MetricsError::AlreadyInstalled) => {
594 tracing::warn!(
595 "a metrics recorder is already installed; continuing \
596 with the existing one and a detached render handle"
597 );
598 metrics::install(&MetricsSettings {
599 exporter: Exporter::None,
600 ..settings.clone()
601 })
602 .map_err(|e| StartError::Metrics(e.to_string()))
603 }
604 Err(e) => Err(StartError::Metrics(e.to_string())),
605 }
606}
607
608#[must_use]
617pub fn metrics_settings(config: &PipelineConfig) -> MetricsSettings {
618 MetricsSettings {
619 exporter: match config.metrics.exporter {
620 MetricsExporter::Prometheus => Exporter::Prometheus,
621 MetricsExporter::None => Exporter::None,
622 },
623 per_partition_detail: config.metrics.per_partition_detail,
624 e2e_basis: match config.metrics.e2e_basis {
625 crate::config::E2eBasis::Ingest => E2eBasis::Ingest,
626 crate::config::E2eBasis::Event => E2eBasis::Event,
627 },
628 }
629}
630
631async fn wait_for_signal() {
632 #[cfg(unix)]
633 {
634 let mut term =
635 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
636 Ok(s) => s,
637 Err(e) => {
638 tracing::error!(error = %e, "failed to install SIGTERM handler");
639 std::future::pending::<()>().await;
640 return;
641 }
642 };
643 tokio::select! {
644 _ = term.recv() => {}
645 r = tokio::signal::ctrl_c() => {
646 if let Err(e) = r {
647 tracing::error!(error = %e, "ctrl_c handler failed");
648 std::future::pending::<()>().await;
649 }
650 }
651 }
652 }
653 #[cfg(not(unix))]
654 {
655 if let Err(e) = tokio::signal::ctrl_c().await {
656 tracing::error!(error = %e, "ctrl_c handler failed");
657 std::future::pending::<()>().await;
658 }
659 }
660}