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::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 source_ct = self.source.component_type().to_string();
184 let source_meter = Meter::for_component(
185 &source_ct,
186 MetricRole::Source,
187 pipeline_name.clone(),
188 "source",
189 );
190
191 let handle = install_or_reuse(&metrics_settings(&self.config))?;
193
194 let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
195 let pipeline_metrics = PipelineMetrics::try_new(&runtime_labels, &self.options.version)
199 .map_err(|e| StartError::Metrics(e.to_string()))?;
200 pipeline_metrics.set_state(PipelineState::Starting);
201 pipeline_metrics.set_threads(threads);
202
203 let checkpoint_metrics = CheckpointMetrics::try_new(
208 &ComponentLabels::new(pipeline_name.clone(), "checkpoint", "checkpoint"),
209 self.config.metrics.per_partition_detail,
210 )
211 .map_err(|e| StartError::Metrics(e.to_string()))?;
212 let controller_source_metrics = Arc::new(
216 SourceMetrics::try_new(&ComponentLabels::new(
217 pipeline_name.clone(),
218 "source",
219 source_ct.clone(),
220 ))
221 .map_err(|e| StartError::Metrics(e.to_string()))?,
222 );
223
224 let health = HealthState::new(threads, HealthThresholds::default());
225
226 let io = match self.io.take() {
231 Some(io) => io,
232 None => tokio::runtime::Builder::new_multi_thread()
233 .worker_threads(self.config.pipeline.io_threads)
234 .thread_name("spate-io")
235 .enable_all()
236 .build()?,
237 };
238
239 if self.options.handle_signals {
244 let shutdown = Arc::clone(&self.shutdown);
245 io.spawn(async move {
246 wait_for_signal().await;
247 tracing::info!("shutdown signal received; draining");
248 shutdown.store(true, Ordering::Relaxed);
249 });
250 }
251
252 match self.sink.probe.take() {
256 Some(probe) => {
257 let health_probe = Arc::clone(&health);
258 io.spawn(async move {
259 loop {
260 let connected = match probe().await {
261 Ok(()) => true,
262 Err(e) => {
263 tracing::warn!(error = %e, "sink probe failed");
264 false
265 }
266 };
267 health_probe.set_sinks_connected(connected);
268 let recheck = if connected {
269 Duration::from_secs(30)
270 } else {
271 Duration::from_secs(5)
272 };
273 tokio::time::sleep(recheck).await;
274 }
275 });
276 }
277 None => health.set_sinks_connected(true),
278 }
279
280 let (events_tx, events_rx) = crossbeam_channel::unbounded::<DriverEvent>();
282 let (to_main_tx, to_main_rx) = crossbeam_channel::unbounded::<ControllerSignal>();
283 let (sink_drained_tx, sink_drained_rx) = crossbeam_channel::unbounded::<()>();
284 let checkpointer = Checkpointer::new();
285
286 let bp_params = BackpressureParams::from_budget(
287 usize::try_from(self.config.backpressure.max_inflight_bytes.as_u64())
288 .unwrap_or(usize::MAX),
289 self.config.backpressure.high_ratio,
290 self.config.backpressure.low_ratio,
291 self.config.backpressure.min_pause,
292 );
293
294 let core_ids: Vec<Option<core_affinity::CoreId>> =
300 if self.config.pipeline.pinning == PinningMode::Compact {
301 let mut ids = core_affinity::get_core_ids().unwrap_or_default();
302 ids.sort_by_key(|c| c.id);
303 if ids.len() < threads {
304 tracing::warn!(
305 cores = ids.len(),
306 threads,
307 "fewer cores than pipeline threads; surplus threads run unpinned"
308 );
309 }
310 (0..threads).map(|i| ids.get(i).copied()).collect()
311 } else {
312 vec![None; threads]
313 };
314
315 let drain_timeout = self.config.checkpoint.drain_timeout;
318
319 let mut control_txs = Vec::with_capacity(threads);
320 let mut driver_handles = Vec::with_capacity(threads);
321 for i in 0..threads {
322 let (control_tx, control_rx) = crossbeam_channel::unbounded::<ThreadControl<S::Lane>>();
323 control_txs.push(control_tx);
324 let ctx = DriverContext {
325 params: DriverParams {
326 thread: i,
327 max_records: self.options.max_records,
328 poll_timeout: self.options.poll_timeout,
329 idle_flush: self.options.idle_flush,
330 blocked_retry: self.options.blocked_retry,
331 queue_low_ratio: self.config.backpressure.low_ratio,
332 },
333 control: control_rx,
334 events: events_tx.clone(),
335 chain: (self.chains)(i),
336 bp: WatermarkController::new(bp_params),
337 budget: Arc::clone(&self.budget),
338 queues: self.sink.queues.clone(),
339 health: Arc::clone(&health),
340 bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
343 pipeline_name.clone(),
344 format!("driver-{i}"),
345 "driver",
346 )),
347 source_metrics: SourceMetrics::shadow(&ComponentLabels::new(
354 pipeline_name.clone(),
355 "source",
356 source_ct.clone(),
357 )),
358 shutdown: Arc::clone(&self.shutdown),
359 };
360 let core = core_ids.get(i).copied().flatten();
361 let spawned = std::thread::Builder::new()
362 .name(format!("spate-pipeline-{i}"))
363 .spawn(move || {
364 if let Some(core) = core
365 && !core_affinity::set_for_current(core)
366 {
367 tracing::warn!(core = core.id, "failed to pin pipeline thread");
368 }
369 run_driver(ctx)
370 });
371 match spawned {
372 Ok(handle) => driver_handles.push(handle),
373 Err(e) => {
374 stop_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
376 return Err(StartError::Io(e));
377 }
378 }
379 }
380
381 drop(self.chains);
386
387 let control_txs_for_stop = control_txs.clone();
391
392 let admin = match io.block_on(AdminServer::bind(
395 self.config.metrics.listen,
396 handle.render_fn(),
397 Arc::clone(&health),
398 )) {
399 Ok(admin) => admin,
400 Err(e) => {
401 stop_drivers(
402 &self.shutdown,
403 &control_txs_for_stop,
404 driver_handles,
405 drain_timeout,
406 );
407 return Err(StartError::Io(e));
408 }
409 };
410 let (admin_stop_tx, admin_stop_rx) = tokio::sync::watch::channel(false);
411 io.spawn(admin.run(admin_stop_rx));
412 {
413 let _guard = io.enter();
415 let _upkeep = handle.spawn_upkeep(Duration::from_secs(5));
416 }
417
418 let controller_ctx = ControllerContext {
419 source: self.source,
420 checkpointer,
421 control_txs,
422 events_rx,
423 to_main: to_main_tx,
424 sink_drained_rx,
425 shutdown: Arc::clone(&self.shutdown),
426 health: Arc::clone(&health),
427 commit_interval: self.config.checkpoint.interval,
428 drain_timeout: self.config.checkpoint.drain_timeout,
429 event_poll_timeout: self.options.event_poll_timeout,
430 max_pending_batches: self.config.checkpoint.max_pending_batches,
431 stalled_fail_after: self.config.checkpoint.stalled_fail_after,
432 checkpoint_metrics,
433 source_metrics: controller_source_metrics,
434 source_meter,
435 per_partition_detail: self.config.metrics.per_partition_detail,
436 pipeline_metrics,
437 };
438 let controller_handle = match std::thread::Builder::new()
439 .name("spate-controller".into())
440 .spawn(move || run_controller(controller_ctx))
441 {
442 Ok(handle) => handle,
443 Err(e) => {
444 stop_drivers(
445 &self.shutdown,
446 &control_txs_for_stop,
447 driver_handles,
448 drain_timeout,
449 );
450 return Err(StartError::Io(e));
451 }
452 };
453
454 let mut sink_drain = None;
456 let mut driver_panic: Option<FatalErrorReport> = None;
457 let sink_runtime = self.sink;
458 let mut drain_fn = Some(sink_runtime.drain);
459 drop(sink_runtime.queues);
460
461 let (mut state, final_watermarks) = loop {
462 match to_main_rx.recv_timeout(Duration::from_millis(100)) {
463 Ok(ControllerSignal::LanesDrained { sink_deadline }) => {
464 for (i, h) in driver_handles.drain(..).enumerate() {
465 if h.join().is_err() {
466 driver_panic.get_or_insert(FatalErrorReport {
467 component: format!("driver-{i}"),
468 reason: "pipeline thread panicked outside the batch guard".into(),
469 });
470 }
471 }
472 if let Some(drain) = drain_fn.take() {
473 let budget = sink_deadline.saturating_duration_since(Instant::now());
474 sink_drain = Some(io.block_on(drain(budget)));
475 }
476 let _ = sink_drained_tx.send(());
477 }
478 Ok(ControllerSignal::Finished(report)) => {
479 break (report.state, report.final_watermarks);
480 }
481 Err(crossbeam_channel::RecvTimeoutError::Timeout)
482 if !controller_handle.is_finished() =>
483 {
484 }
486 Err(_) => {
487 stop_drivers(
494 &self.shutdown,
495 &control_txs_for_stop,
496 std::mem::take(&mut driver_handles),
497 drain_timeout,
498 );
499 if let Some(drain) = drain_fn.take() {
500 sink_drain = Some(io.block_on(drain(drain_timeout)));
501 }
502 break (
503 ExitState::Failed(FatalErrorReport {
504 component: "controller".into(),
505 reason: "controller thread panicked".into(),
506 }),
507 Vec::new(),
508 );
509 }
510 }
511 };
512 for h in driver_handles {
515 let _ = h.join();
516 }
517 if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
520 state = ExitState::Failed(report);
521 }
522
523 let _ = admin_stop_tx.send(true);
524 io.shutdown_timeout(Duration::from_secs(2));
525 let _ = controller_handle.join();
526
527 Ok(ExitReport {
528 state,
529 sink_drain,
530 final_watermarks,
531 })
532 }
533}
534
535fn stop_drivers<L>(
545 shutdown: &AtomicBool,
546 control_txs: &[crossbeam_channel::Sender<ThreadControl<L>>],
547 driver_handles: Vec<std::thread::JoinHandle<DriverExit>>,
548 grace: Duration,
549) {
550 shutdown.store(true, Ordering::Relaxed);
551 let deadline = Instant::now() + grace;
552 let barrier = DrainBarrier::new(control_txs.len());
553 for tx in control_txs {
554 let _ = tx.send(ThreadControl::Shutdown {
555 barrier: barrier.clone(),
556 deadline,
557 });
558 }
559 for handle in driver_handles {
560 let _ = handle.join();
561 }
562}
563
564pub(crate) fn install_or_reuse(settings: &MetricsSettings) -> Result<MetricsHandle, StartError> {
569 match metrics::install(settings) {
570 Ok(h) => Ok(h),
571 Err(metrics::MetricsError::AlreadyInstalled) => {
572 tracing::warn!(
573 "a metrics recorder is already installed; continuing \
574 with the existing one and a detached render handle"
575 );
576 metrics::install(&MetricsSettings {
577 exporter: Exporter::None,
578 ..settings.clone()
579 })
580 .map_err(|e| StartError::Metrics(e.to_string()))
581 }
582 Err(e) => Err(StartError::Metrics(e.to_string())),
583 }
584}
585
586#[must_use]
595pub fn metrics_settings(config: &PipelineConfig) -> MetricsSettings {
596 MetricsSettings {
597 exporter: match config.metrics.exporter {
598 MetricsExporter::Prometheus => Exporter::Prometheus,
599 MetricsExporter::None => Exporter::None,
600 },
601 listen: config.metrics.listen,
602 per_partition_detail: config.metrics.per_partition_detail,
603 e2e_basis: match config.metrics.e2e_basis {
604 crate::config::E2eBasis::Ingest => E2eBasis::Ingest,
605 crate::config::E2eBasis::Event => E2eBasis::Event,
606 },
607 }
608}
609
610async fn wait_for_signal() {
611 #[cfg(unix)]
612 {
613 let mut term =
614 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
615 Ok(s) => s,
616 Err(e) => {
617 tracing::error!(error = %e, "failed to install SIGTERM handler");
618 std::future::pending::<()>().await;
619 return;
620 }
621 };
622 tokio::select! {
623 _ = term.recv() => {}
624 r = tokio::signal::ctrl_c() => {
625 if let Err(e) = r {
626 tracing::error!(error = %e, "ctrl_c handler failed");
627 std::future::pending::<()>().await;
628 }
629 }
630 }
631 }
632 #[cfg(not(unix))]
633 {
634 if let Err(e) = tokio::signal::ctrl_c().await {
635 tracing::error!(error = %e, "ctrl_c handler failed");
636 std::future::pending::<()>().await;
637 }
638 }
639}