Skip to main content

meathook/
runtime.rs

1//! [`Meathook`]: the supervisor runtime.
2//!
3//! Each pipeline is registered as a **factory closure** and runs in its own
4//! tokio task. Panicked pipelines are rebuilt from their factory and
5//! respawned with exponential backoff (durable layers re-run crash recovery
6//! on whatever the dead task left behind). On SIGTERM/ctrl-c every pipeline
7//! drains its sink stack before the runtime returns.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::hash::Hash;
12use std::io;
13use std::pin::Pin;
14use std::time::Duration;
15
16use tokio::signal;
17use tokio::signal::unix;
18use tokio::task;
19use tokio::task::JoinSet;
20use tokio::time;
21use tokio::time::Instant;
22use tokio_util::sync::CancellationToken;
23use tracing::{error, info, warn};
24
25use crate::collector::Collector;
26use crate::pipeline::Pipeline;
27use crate::sink::Sink;
28
29type PipelineFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
30type PipelineFactory = Box<dyn Fn(CancellationToken) -> PipelineFuture + Send + Sync>;
31
32/// Error from [`Meathook::run`].
33#[derive(Debug, thiserror::Error)]
34pub enum RuntimeError {
35    #[error("failed to install signal handler: {0}")]
36    Signal(#[source] io::Error),
37}
38
39/// Builder for the [`Meathook`] runtime.
40pub struct MeathookBuilder {
41    factories: Vec<PipelineFactory>,
42    max_consecutive_failures: u32,
43    base_backoff: Duration,
44    /// A pipeline alive at least this long resets its failure streak.
45    failure_window: Duration,
46}
47
48impl Default for MeathookBuilder {
49    fn default() -> Self {
50        Self {
51            factories: vec![],
52            max_consecutive_failures: 5,
53            base_backoff: Duration::from_secs(1),
54            failure_window: Duration::from_secs(300),
55        }
56    }
57}
58
59impl MeathookBuilder {
60    /// Register a pipeline via its factory. The factory is invoked for the
61    /// initial spawn and again after every panic, so the whole stack
62    /// (collector, sink layers, spool recovery) is rebuilt fresh.
63    #[must_use]
64    pub fn pipeline<C, S, F, K, Make>(mut self, factory: Make) -> Self
65    where
66        Make: Fn() -> Pipeline<C, S, F, K> + Send + Sync + 'static,
67        C: Collector + Send + 'static,
68        S: Sink<C::Record> + Send + 'static,
69        F: FnMut(&C::Record) -> K + Send + 'static,
70        K: Eq + Hash + Send + 'static,
71    {
72        self.factories
73            .push(Box::new(move |cancel| Box::pin(factory().run(cancel))));
74        self
75    }
76
77    /// Give up respawning a pipeline after this many consecutive panics
78    /// (default 5).
79    #[must_use]
80    pub fn max_consecutive_failures(mut self, max: u32) -> Self {
81        self.max_consecutive_failures = max;
82        self
83    }
84
85    /// Base delay for the exponential respawn backoff (default 1s).
86    #[must_use]
87    pub fn base_backoff(mut self, base: Duration) -> Self {
88        self.base_backoff = base;
89        self
90    }
91
92    #[must_use]
93    pub fn build(self) -> Meathook {
94        Meathook { builder: self }
95    }
96
97    /// Shorthand for `.build().run()`.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the runtime cannot install its signal handler.
102    pub async fn run(self) -> Result<(), RuntimeError> {
103        self.build().run().await
104    }
105}
106
107/// Supervisor runtime owning every registered pipeline.
108pub struct Meathook {
109    builder: MeathookBuilder,
110}
111
112struct PipelineState {
113    failures: u32,
114    spawned_at: Instant,
115    gave_up: bool,
116}
117
118impl Meathook {
119    #[must_use]
120    pub fn builder() -> MeathookBuilder {
121        MeathookBuilder::default()
122    }
123
124    /// Run until SIGTERM/ctrl-c, then drain every pipeline's sink stack
125    /// before returning.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the runtime cannot install its signal handler.
130    pub async fn run(self) -> Result<(), RuntimeError> {
131        let cancel = CancellationToken::new();
132        let signal_cancel = cancel.clone();
133
134        #[cfg(unix)]
135        let mut sigterm =
136            unix::signal(unix::SignalKind::terminate()).map_err(RuntimeError::Signal)?;
137        tokio::spawn(async move {
138            #[cfg(unix)]
139            tokio::select! {
140                _ = signal::ctrl_c() => info!("received ctrl-c; shutting down"),
141                _ = sigterm.recv() => info!("received SIGTERM; shutting down"),
142            }
143            #[cfg(not(unix))]
144            if signal::ctrl_c().await.is_ok() {
145                info!("received ctrl-c; shutting down");
146            }
147            signal_cancel.cancel();
148        });
149
150        self.run_with_shutdown(cancel).await;
151        Ok(())
152    }
153
154    /// Run until `cancel` fires. Exposed for embedding and tests; signal
155    /// handling is layered on top by [`run`](Meathook::run).
156    pub async fn run_with_shutdown(self, cancel: CancellationToken) {
157        let MeathookBuilder {
158            factories,
159            max_consecutive_failures,
160            base_backoff,
161            failure_window,
162        } = self.builder;
163
164        let mut join_set = JoinSet::new();
165        let mut states: Vec<PipelineState> = Vec::with_capacity(factories.len());
166        let mut task_pipeline: HashMap<task::Id, usize> = HashMap::new();
167
168        for (index, factory) in factories.iter().enumerate() {
169            let handle = join_set.spawn(factory(cancel.child_token()));
170            task_pipeline.insert(handle.id(), index);
171            states.push(PipelineState {
172                failures: 0,
173                spawned_at: Instant::now(),
174                gave_up: false,
175            });
176        }
177
178        info!(pipelines = factories.len(), "meathook runtime started");
179
180        while let Some(joined) = join_set.join_next_with_id().await {
181            let (task_id, panicked) = match joined {
182                Ok((id, ())) => (id, false),
183                Err(join_error) => {
184                    if join_error.is_cancelled() {
185                        continue;
186                    }
187                    (join_error.id(), true)
188                }
189            };
190            let Some(&index) = task_pipeline.get(&task_id) else {
191                continue;
192            };
193
194            if !panicked || cancel.is_cancelled() {
195                // Clean exit (cancellation drained the stack), or we are
196                // shutting down anyway: don't respawn.
197                continue;
198            }
199
200            let state = &mut states[index];
201            if state.spawned_at.elapsed() >= failure_window {
202                state.failures = 0;
203            }
204            state.failures += 1;
205
206            if state.failures > max_consecutive_failures {
207                if !state.gave_up {
208                    state.gave_up = true;
209                    error!(
210                        pipeline = index,
211                        failures = state.failures,
212                        "pipeline keeps panicking; giving up on it"
213                    );
214                }
215                continue;
216            }
217
218            let backoff = base_backoff.saturating_mul(1 << (state.failures - 1).min(16));
219            warn!(
220                pipeline = index,
221                failures = state.failures,
222                ?backoff,
223                "pipeline panicked; respawning from factory"
224            );
225
226            tokio::select! {
227                () = cancel.cancelled() => continue,
228                () = time::sleep(backoff) => {}
229            }
230
231            state.spawned_at = Instant::now();
232            let handle = join_set.spawn(factories[index](cancel.child_token()));
233            task_pipeline.insert(handle.id(), index);
234        }
235
236        info!("meathook runtime stopped");
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::convert::Infallible;
243    use std::sync::Arc;
244    use std::sync::atomic::{AtomicUsize, Ordering};
245
246    use super::*;
247    use crate::test_util::SharedSink;
248
249    /// Panics on its very first collect of the process, then behaves: lets
250    /// the test observe respawn-from-factory.
251    struct PanicsOnce {
252        global_calls: Arc<AtomicUsize>,
253    }
254
255    impl Collector for PanicsOnce {
256        type Record = usize;
257        type Error = Infallible;
258
259        fn name(&self) -> &'static str {
260            "panics-once"
261        }
262
263        async fn collect(&mut self) -> Result<Vec<usize>, Infallible> {
264            let call = self.global_calls.fetch_add(1, Ordering::SeqCst);
265            assert!(call != 0, "boom");
266            Ok(vec![call])
267        }
268    }
269
270    #[tokio::test(start_paused = true)]
271    async fn respawns_panicked_pipeline_from_factory() {
272        let sink = SharedSink::<usize>::new();
273        let calls = Arc::new(AtomicUsize::new(0));
274        let built = Arc::new(AtomicUsize::new(0));
275
276        let runtime = {
277            let sink = sink.clone();
278            let calls = Arc::clone(&calls);
279            let built = Arc::clone(&built);
280            Meathook::builder()
281                .pipeline(move || {
282                    built.fetch_add(1, Ordering::SeqCst);
283                    Pipeline::new(
284                        PanicsOnce {
285                            global_calls: Arc::clone(&calls),
286                        },
287                        sink.clone(),
288                        Duration::from_secs(60),
289                    )
290                })
291                .build()
292        };
293
294        let cancel = CancellationToken::new();
295        let handle = tokio::spawn(runtime.run_with_shutdown(cancel.clone()));
296
297        time::sleep(Duration::from_secs(200)).await;
298        cancel.cancel();
299        handle.await.unwrap();
300
301        assert!(built.load(Ordering::SeqCst) >= 2, "factory not re-invoked");
302        assert!(!sink.records().is_empty(), "no records after respawn");
303        assert!(sink.flushed(), "stack not drained on shutdown");
304    }
305
306    #[tokio::test(start_paused = true)]
307    async fn gives_up_after_max_consecutive_failures() {
308        struct AlwaysPanics;
309        impl Collector for AlwaysPanics {
310            type Record = usize;
311            type Error = Infallible;
312            fn name(&self) -> &'static str {
313                "always-panics"
314            }
315            async fn collect(&mut self) -> Result<Vec<usize>, Infallible> {
316                panic!("boom");
317            }
318        }
319
320        let built = Arc::new(AtomicUsize::new(0));
321        let runtime = {
322            let built = Arc::clone(&built);
323            Meathook::builder()
324                .max_consecutive_failures(2)
325                .pipeline(move || {
326                    built.fetch_add(1, Ordering::SeqCst);
327                    Pipeline::new(
328                        AlwaysPanics,
329                        SharedSink::<usize>::new(),
330                        Duration::from_secs(1),
331                    )
332                })
333                .build()
334        };
335
336        let cancel = CancellationToken::new();
337        let handle = tokio::spawn(runtime.run_with_shutdown(cancel.clone()));
338        time::sleep(Duration::from_secs(3600)).await;
339        cancel.cancel();
340        handle.await.unwrap();
341
342        // Initial spawn + 2 respawns, then the supervisor gives up.
343        assert_eq!(built.load(Ordering::SeqCst), 3);
344    }
345}