Skip to main content

spate_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, 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/// Knobs that are not part of the user-facing YAML (loop granularities,
22/// test hooks). The defaults suit production; tests shrink the timings.
23#[derive(Clone, Debug)]
24pub struct RuntimeOptions {
25    /// Install SIGTERM/SIGINT handlers that trigger a graceful drain.
26    /// Disable in tests and drive [`ShutdownHandle`] instead.
27    pub handle_signals: bool,
28    /// Max payloads per lane poll.
29    pub max_records: usize,
30    /// Lane poll timeout (also the paused/idle loop sleep).
31    pub poll_timeout: Duration,
32    /// Flush the chain after this long without new data.
33    pub idle_flush: Duration,
34    /// Sleep between retries of a blocked batch.
35    pub blocked_retry: Duration,
36    /// Controller `poll_events` timeout.
37    pub event_poll_timeout: Duration,
38    /// Version string published on `spate_pipeline_info`.
39    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/// Triggers a graceful drain from anywhere (tests, custom signal wiring).
57#[derive(Clone, Debug)]
58pub struct ShutdownHandle(Arc<AtomicBool>);
59
60impl ShutdownHandle {
61    /// Begin the drain. Idempotent.
62    pub fn trigger(&self) {
63        self.0.store(true, Ordering::Relaxed);
64    }
65}
66
67/// The pipeline could not start.
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum StartError {
71    /// Invalid effective configuration.
72    #[error("invalid runtime configuration: {0}")]
73    Config(String),
74    /// The metrics exporter could not be installed.
75    #[error("metrics: {0}")]
76    Metrics(String),
77    /// The admin server could not take its address. Set `admin.listen` to a
78    /// free address, or to `none` for a pipeline that needs no server.
79    #[error("admin.listen: cannot bind {addr}: {source}")]
80    AdminBind {
81        /// The address `admin.listen` asked for.
82        addr: SocketAddr,
83        /// Why the kernel refused it.
84        source: std::io::Error,
85    },
86    /// The I/O runtime or a pipeline thread could not start.
87    #[error("io: {0}")]
88    Io(#[from] std::io::Error),
89}
90
91/// One pipeline process: source, per-thread chains, and a sink.
92///
93/// The caller creates the shared [`InflightBudget`] first and wires it into
94/// the chain terminals (which `add` on enqueue) and the sink workers (which
95/// `sub` on durable write or abandonment) before handing everything here.
96pub 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    /// Assemble a runtime. `chains` builds one erased chain per pipeline
118    /// thread (thread index in).
119    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    /// Override runtime options (returns `self` for chaining).
139    #[must_use]
140    pub fn with_options(mut self, options: RuntimeOptions) -> Self {
141        self.options = options;
142        self
143    }
144
145    /// Use a caller-owned tokio runtime as the I/O runtime instead of
146    /// building one inside [`run`](Self::run). For assemblies whose
147    /// connectors needed a handle before the runtime existed (sink workers
148    /// spawned at construction, schema-registry fetchers, async pre-flight
149    /// validation). `run` shuts it down on exit as it does the internally
150    /// built one; connector tasks spawned on it earlier keep running until
151    /// then. Without this, assemblies end up running a second runtime,
152    /// doubling `pipeline.io_threads`.
153    #[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    /// A handle that triggers a graceful drain.
160    #[must_use]
161    pub fn shutdown_handle(&self) -> ShutdownHandle {
162        ShutdownHandle(Arc::clone(&self.shutdown))
163    }
164
165    /// Effective pipeline thread count: the config override, else
166    /// `available_parallelism` minus the I/O reserve (I/O workers + the
167    /// controller), at least 1. `available_parallelism` respects cgroup
168    /// CPU quotas, so Kubernetes limits size this correctly. Pods without
169    /// limits see the node's cores; set `pipeline.threads` explicitly there.
170    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    /// Run the pipeline to completion (blocking). Returns when the
180    /// pipeline drained after a shutdown trigger/signal or failed.
181    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        // The source's declared component_type feeds both its stage-metric
189        // labels and the namespace of the custom-metrics Meter it receives at
190        // `open`. Captured here, before `self.source` moves into the controller.
191        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        // Observability first: everything after this records metrics.
200        let handle = install_or_reuse(&metrics_settings(&self.config))?;
201
202        let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
203        // Fallible: a second live pipeline of this name in one process would
204        // publish state and lifecycle gauges over this one's (see "Series
205        // ownership" in `docs/METRICS.md`). Refuse to start instead.
206        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        // The controller's handle sets, resolved here rather than at the
212        // controller's own construction below. Both claim series, and a
213        // duplicate must fail before any driver thread exists. Past that
214        // point a bare `?` would leak the threads instead of stopping them.
215        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        // The owner of the source series. It publishes active lanes, and its
221        // clone goes to the source, which is the only thing that can measure
222        // lag. The per-thread instances are shadows of this one.
223        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        // I/O runtime: sink workers (spawned by the caller-built SinkPool
235        // onto this runtime via its own handle), admin server, upkeep,
236        // signals. A caller-owned runtime (`with_io_runtime`) is adopted
237        // instead of built; either way this function owns its shutdown.
238        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        // Admin bind, upkeep, and the controller thread are started *after*
248        // the driver threads (below) so a failure in any of them can stop
249        // the already-running drivers instead of leaking them.
250
251        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        // Sink readiness: probe at startup and periodically (tighter while
261        // failing), driving the sinks-connected half of `/readyz`. No probe
262        // hook means nothing to check, so report connected.
263        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        // Wiring.
289        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        // Compact pinning: thread i on core i, low cores first, leaving the
303        // remaining cores for the I/O runtime and librdkafka's threads.
304        // Note for Kubernetes: exclusive cores require the kubelet static
305        // CPU manager with Guaranteed QoS and integer CPU requests;
306        // otherwise pinning only sets affinity within the shared cpuset.
307        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        // Short grace for cleanup-time driver stops (startup errors, a
324        // controller panic): the same budget the drain barrier uses.
325        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                // Each driver's backpressure series is its own (`driver-{i}`),
350                // so every thread owns what it publishes.
351                bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
352                    pipeline_name.clone(),
353                    format!("driver-{i}"),
354                    "driver",
355                )),
356                // A shadow. Every thread counts the records and polls it
357                // performed (those sum), but the source *gauges* (lag and
358                // active lanes) belong to the controller's instance below,
359                // which is the only one that sees the assignment and the
360                // only one the source itself is handed. A claiming
361                // constructor here would take the series hostage from it.
362                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 the drivers already spawned before bailing out.
384                    stop_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
385                    return Err(StartError::Io(e));
386                }
387            }
388        }
389
390        // Factories capture ShardQueues clones (their terminals need them),
391        // and the sink only drains once every queue clone is gone. Holding
392        // the factory through the drain would deadlock shutdown.
393        drop(self.chains);
394
395        // A cloned set of driver control senders kept by main, so it can stop
396        // the drivers itself if a later startup step fails or the controller
397        // thread dies (the originals are moved into the controller below).
398        let control_txs_for_stop = control_txs.clone();
399
400        // Admin bind now that the drivers are live: a bind failure (e.g. the
401        // address is taken) stops them instead of leaking them.
402        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            // spawn_upkeep uses tokio::spawn internally; enter the runtime.
435            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        // Main: wait for the controller's choreography.
475        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                    // Controller still working; keep waiting on the 100ms tick.
505                }
506                Err(_) => {
507                    // The controller thread ended without a Finished report
508                    // (a timeout with a finished handle, or the signal
509                    // channel disconnected), so it panicked. It never told
510                    // the drivers to stop and never set the shutdown flag, so
511                    // an untimed join here would wedge forever. Stop them
512                    // ourselves, drain the sink, and fail the run.
513                    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        // Drivers are already joined on the drain path; on the
533        // controller-died path make a best effort not to leak them.
534        for h in driver_handles {
535            let _ = h.join();
536        }
537        // A driver that panicked outside the batch guard is a bug worth
538        // failing the run over, even if the drain otherwise completed.
539        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
557/// Set the shutdown flag, tell every driver thread to stop within a bounded
558/// drain barrier, and join them. Shared by the startup-error paths and the
559/// controller-death path so an early failure or a controller panic never
560/// leaves running pinned pipeline threads behind (`run` is a library API).
561///
562/// Joining bounds this. A driver observes the shutdown flag (abandoning any
563/// blocked batch) and the `Shutdown` control message (flushing within
564/// `grace`), then exits and drops its chain, closing the shard queues so the
565/// sink can drain afterwards.
566fn 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
586/// Install the exporter, degrading gracefully when a foreign recorder
587/// already owns the process. The pipeline keeps running against the
588/// existing recorder with a detached (empty-rendering) handle for the
589/// admin server. Shared by the runtime and the pipeline builder.
590pub(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/// The [`MetricsSettings`] a pipeline configuration maps to.
609///
610/// Assemblies that pre-register metric handles (sink shard metrics, custom
611/// metrics) should call
612/// [`metrics::install`](crate::metrics::install)`(&metrics_settings(&config))`
613/// **before** constructing them; the runtime's own install then reuses the
614/// exporter. Handles built before any install bind to the no-op recorder
615/// and render nothing.
616#[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}