Skip to main content

miden_node_utils/
tasks.rs

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