Skip to main content

miden_node_utils/
tasks.rs

1use std::collections::HashMap;
2use std::future::Future;
3
4use anyhow::Context;
5use tokio::task::{Id, JoinError, JoinSet};
6
7use crate::shutdown::CancellationToken;
8
9/// A named task set for supervising concurrently-running Tokio tasks.
10///
11/// Dropping a task set aborts all tasks that are still running.
12pub struct Tasks {
13    handles: JoinSet<anyhow::Result<()>>,
14    names: HashMap<Id, String>,
15}
16
17impl Default for Tasks {
18    fn default() -> Self {
19        Self {
20            handles: JoinSet::new(),
21            names: HashMap::new(),
22        }
23    }
24}
25
26impl Tasks {
27    /// Creates an empty task set.
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Spawns a named task into the set.
33    pub fn spawn(
34        &mut self,
35        name: impl Into<String>,
36        task: impl Future<Output = anyhow::Result<()>> + Send + 'static,
37    ) -> Id {
38        let id = self.handles.spawn(task).id();
39        self.names.insert(id, name.into());
40        id
41    }
42
43    /// Spawns a named task that does not return an error.
44    pub fn spawn_infallible(
45        &mut self,
46        name: impl Into<String>,
47        task: impl Future<Output = ()> + Send + 'static,
48    ) -> Id {
49        self.spawn(name, async move {
50            task.await;
51            Ok(())
52        })
53    }
54
55    /// Waits for the next task to complete.
56    pub async fn join_next(&mut self) -> Option<(String, Result<anyhow::Result<()>, JoinError>)> {
57        let result = self.handles.join_next_with_id().await?;
58        let id = match &result {
59            Ok((id, _)) => *id,
60            Err(err) => err.id(),
61        };
62        let name = self.names.remove(&id).unwrap_or_else(|| "unknown".to_string());
63        let result = result.map(|(_, output)| output);
64
65        Some((name, result))
66    }
67
68    /// Returns `true` if no tasks are currently in the set.
69    pub fn is_empty(&self) -> bool {
70        self.handles.is_empty()
71    }
72
73    /// Returns the number of tasks currently in the set.
74    pub fn len(&self) -> usize {
75        self.handles.len()
76    }
77
78    /// Waits for the next task to complete, treating that completion as an error.
79    ///
80    /// This is intended for supervised task sets where every task is expected to run indefinitely.
81    pub async fn join_next_as_error(&mut self) -> anyhow::Result<()> {
82        let Some((task, result)) = self.join_next().await else {
83            anyhow::bail!("task set is empty");
84        };
85
86        Self::unexpected_completion(&task, result)
87    }
88
89    /// Waits for either an unexpected task completion or a shutdown request.
90    ///
91    /// Before shutdown, any task completion is treated as fatal because this type supervises
92    /// long-running tasks. Such a completion triggers the shutdown itself: the token is cancelled
93    /// and the remaining tasks are drained before the error is returned. Returning without
94    /// draining would drop the set and abort the surviving tasks mid-work — e.g. the store's
95    /// block writer between its database commit and tree update, tearing persistent state.
96    ///
97    /// Once `token` is cancelled (whether externally or by a failure here), clean task exits are
98    /// accepted and this method waits for all tracked tasks to finish. The first failure observed
99    /// is returned as the root cause; subsequent failures are logged, since they are often
100    /// knock-on effects of the first.
101    pub async fn join_next_or_cancelled(&mut self, token: CancellationToken) -> anyhow::Result<()> {
102        let mut outcome = Ok(());
103        while !token.is_cancelled() {
104            tokio::select! {
105                biased;
106                () = token.cancelled() => break,
107                result = self.join_next() => {
108                    let Some((task, result)) = result else {
109                        anyhow::bail!("task set is empty");
110                    };
111                    outcome = Self::unexpected_completion(&task, result);
112                    // Shut the remaining tasks down and fall through to the drain below.
113                    token.cancel();
114                },
115            }
116        }
117
118        while let Some((task, result)) = self.join_next().await {
119            match (&outcome, Self::shutdown_completion(&task, result)) {
120                // No failure so far: this task's result (clean or failed) becomes the outcome.
121                (Ok(()), result) => outcome = result,
122                // A failure is already recorded as the root cause; later failures are often
123                // knock-on effects of it, so log them rather than mask it.
124                (Err(_), Err(err)) => {
125                    tracing::warn!(task = %task, error = %format!("{err:#}"), "task failed during shutdown");
126                },
127                // A failure is already recorded and this task exited cleanly: nothing to add.
128                (Err(_), Ok(())) => {},
129            }
130        }
131
132        outcome
133    }
134
135    /// Interprets a task completion observed *before* shutdown was requested.
136    ///
137    /// Supervised tasks are expected to run until shutdown, so every completion — even a clean
138    /// exit — is an error here; the variants only differ in how much context the error carries
139    /// (task failure, or a panicked/aborted task surfacing as a [`JoinError`]).
140    fn unexpected_completion(
141        task: &str,
142        result: Result<anyhow::Result<()>, JoinError>,
143    ) -> anyhow::Result<()> {
144        match result {
145            Ok(Ok(())) => anyhow::bail!("task {task} completed unexpectedly"),
146            Ok(Err(err)) => Err(err).with_context(|| format!("task {task} failed")),
147            Err(err) => Err(err).with_context(|| format!("task {task} failed to join")),
148        }
149    }
150
151    /// Interprets a task completion observed *after* shutdown was requested.
152    ///
153    /// During shutdown a clean exit is the expected outcome, and a cancelled task is also fine —
154    /// abort is how a dropped set winds tasks down. A task error or a panic (a non-cancellation
155    /// [`JoinError`]) is still a failure worth reporting.
156    fn shutdown_completion(
157        task: &str,
158        result: Result<anyhow::Result<()>, JoinError>,
159    ) -> anyhow::Result<()> {
160        match result {
161            Ok(Ok(())) => Ok(()),
162            Ok(Err(err)) => Err(err).with_context(|| format!("task {task} failed during shutdown")),
163            Err(err) if err.is_cancelled() => Ok(()),
164            Err(err) => Err(err).with_context(|| format!("task {task} failed to join")),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::time::Duration;
172
173    use super::*;
174
175    #[tokio::test]
176    async fn join_next_or_cancelled_accepts_clean_task_completion_after_cancellation() {
177        let token = crate::shutdown::CancellationToken::new();
178        let mut tasks = Tasks::new();
179        tasks.spawn("worker", {
180            let token = token.clone();
181            async move {
182                token.cancelled().await;
183                Ok(())
184            }
185        });
186
187        token.cancel();
188
189        tasks
190            .join_next_or_cancelled(token)
191            .await
192            .expect("clean shutdown should not be treated as an error");
193    }
194
195    #[tokio::test]
196    async fn join_next_or_cancelled_treats_task_completion_before_cancellation_as_error() {
197        let token = crate::shutdown::CancellationToken::new();
198        let mut tasks = Tasks::new();
199        tasks.spawn("worker", async { Ok(()) });
200
201        let err = tasks
202            .join_next_or_cancelled(token)
203            .await
204            .expect_err("unexpected task completion should fail before shutdown");
205
206        assert_eq!(err.to_string(), "task worker completed unexpectedly");
207    }
208
209    #[tokio::test]
210    async fn join_next_or_cancelled_drains_remaining_tasks_after_a_failure() {
211        use std::sync::Arc;
212        use std::sync::atomic::{AtomicBool, Ordering};
213
214        let token = crate::shutdown::CancellationToken::new();
215        let mut tasks = Tasks::new();
216        let survivor_finished = Arc::new(AtomicBool::new(false));
217
218        tasks.spawn("failing", async { anyhow::bail!("boom") });
219        tasks.spawn("survivor", {
220            let token = token.clone();
221            let finished = Arc::clone(&survivor_finished);
222            async move {
223                token.cancelled().await;
224                // Work past the cancellation point: an aborted task would never get here.
225                tokio::time::sleep(Duration::from_millis(10)).await;
226                finished.store(true, Ordering::Relaxed);
227                Ok(())
228            }
229        });
230
231        let err = tasks
232            .join_next_or_cancelled(token.clone())
233            .await
234            .expect_err("the failing task's error should be returned");
235
236        assert_eq!(err.to_string(), "task failing failed");
237        assert!(token.is_cancelled(), "a task failure should trigger shutdown");
238        assert!(tasks.is_empty(), "all tasks should be drained before returning");
239        assert!(
240            survivor_finished.load(Ordering::Relaxed),
241            "surviving tasks should shut down gracefully, not be aborted",
242        );
243    }
244
245    #[tokio::test]
246    async fn join_next_or_cancelled_drains_past_failures_during_shutdown() {
247        use std::sync::Arc;
248        use std::sync::atomic::{AtomicBool, Ordering};
249
250        let token = crate::shutdown::CancellationToken::new();
251        let mut tasks = Tasks::new();
252        let survivor_finished = Arc::new(AtomicBool::new(false));
253
254        tasks.spawn("failing", {
255            let token = token.clone();
256            async move {
257                token.cancelled().await;
258                anyhow::bail!("boom")
259            }
260        });
261        tasks.spawn("survivor", {
262            let token = token.clone();
263            let finished = Arc::clone(&survivor_finished);
264            async move {
265                token.cancelled().await;
266                tokio::time::sleep(Duration::from_millis(10)).await;
267                finished.store(true, Ordering::Relaxed);
268                Ok(())
269            }
270        });
271
272        token.cancel();
273
274        let err = tasks
275            .join_next_or_cancelled(token)
276            .await
277            .expect_err("a failure during shutdown should be reported");
278
279        assert_eq!(err.to_string(), "task failing failed during shutdown");
280        assert!(tasks.is_empty(), "draining should continue past the failed task");
281        assert!(
282            survivor_finished.load(Ordering::Relaxed),
283            "surviving tasks should shut down gracefully, not be aborted",
284        );
285    }
286
287    #[tokio::test]
288    async fn join_next_or_cancelled_waits_for_all_tasks_to_complete_after_cancellation() {
289        let token = crate::shutdown::CancellationToken::new();
290        let mut tasks = Tasks::new();
291        tasks.spawn("worker-a", {
292            let token = token.clone();
293            async move {
294                token.cancelled().await;
295                Ok(())
296            }
297        });
298        tasks.spawn("worker-b", {
299            let token = token.clone();
300            async move {
301                token.cancelled().await;
302                tokio::time::sleep(Duration::from_millis(10)).await;
303                Ok(())
304            }
305        });
306
307        token.cancel();
308
309        tasks
310            .join_next_or_cancelled(token)
311            .await
312            .expect("shutdown should wait for all clean task exits");
313        assert!(tasks.is_empty());
314    }
315}