Skip to main content

dynomite/core/
task.rs

1//! Periodic task scheduler built on `tokio::time::interval`.
2//!
3//! Timer-wheel maintenance is delegated to the tokio runtime:
4//! callers register a periodic callback through [`task_register`]
5//! and receive a [`TaskHandle`] that cancels the underlying tokio
6//! task when dropped or explicitly cancelled.
7//!
8//! A one-shot variant, [`task_schedule_once`], fires a single
9//! delayed callback. The runtime drives both APIs, so there is no
10//! per-iteration "time to next task" / "execute expired tasks"
11//! bookkeeping; tokio's reactor performs that work transparently.
12//!
13//! # Examples
14//!
15//! ```
16//! use std::sync::atomic::{AtomicUsize, Ordering};
17//! use std::sync::Arc;
18//! use std::time::Duration;
19//! use dynomite::core::task::task_register;
20//!
21//! let rt = tokio::runtime::Runtime::new().unwrap();
22//! rt.block_on(async {
23//!     let counter = Arc::new(AtomicUsize::new(0));
24//!     let c = counter.clone();
25//!     let handle = task_register(Duration::from_millis(5), Arc::new(move || {
26//!         c.fetch_add(1, Ordering::Relaxed);
27//!     }));
28//!     tokio::time::sleep(Duration::from_millis(40)).await;
29//!     handle.cancel();
30//!     assert!(counter.load(Ordering::Relaxed) >= 1);
31//! });
32//! ```
33
34use std::sync::Arc;
35use std::time::Duration;
36
37use tokio_util::sync::CancellationToken;
38
39/// A handle that cancels a registered task.
40///
41/// Dropping the handle without calling [`TaskHandle::cancel`] leaves
42/// the task running (the tokio task holds a clone of the cancellation
43/// token). Call [`TaskHandle::cancel`] to stop the task explicitly.
44///
45/// # Examples
46///
47/// ```
48/// use std::sync::Arc;
49/// use std::time::Duration;
50/// use dynomite::core::task::task_register;
51///
52/// let rt = tokio::runtime::Runtime::new().unwrap();
53/// rt.block_on(async {
54///     let h = task_register(Duration::from_millis(50), Arc::new(|| {}));
55///     assert!(!h.is_cancelled());
56///     h.cancel();
57///     assert!(h.is_cancelled());
58/// });
59/// ```
60#[derive(Debug, Clone)]
61pub struct TaskHandle {
62    token: CancellationToken,
63}
64
65impl TaskHandle {
66    /// Cancel the task.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use std::sync::Arc;
72    /// use std::time::Duration;
73    /// use dynomite::core::task::task_register;
74    ///
75    /// let rt = tokio::runtime::Runtime::new().unwrap();
76    /// rt.block_on(async {
77    ///     let h = task_register(Duration::from_millis(50), Arc::new(|| {}));
78    ///     h.cancel();
79    ///     assert!(h.is_cancelled());
80    /// });
81    /// ```
82    pub fn cancel(&self) {
83        self.token.cancel();
84    }
85
86    /// Whether the task has already been cancelled.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use std::sync::Arc;
92    /// use std::time::Duration;
93    /// use dynomite::core::task::task_register;
94    ///
95    /// let rt = tokio::runtime::Runtime::new().unwrap();
96    /// rt.block_on(async {
97    ///     let h = task_register(Duration::from_millis(50), Arc::new(|| {}));
98    ///     assert!(!h.is_cancelled());
99    ///     h.cancel();
100    ///     assert!(h.is_cancelled());
101    /// });
102    /// ```
103    pub fn is_cancelled(&self) -> bool {
104        self.token.is_cancelled()
105    }
106}
107
108/// Register a periodic task that fires its callback every `period`.
109///
110/// The first invocation occurs after `period` elapses. The task runs
111/// on the current tokio runtime, so this function must be called from
112/// inside one (e.g. inside `#[tokio::main]` or a `block_on`).
113///
114/// # Examples
115///
116/// ```
117/// use std::sync::atomic::{AtomicUsize, Ordering};
118/// use std::sync::Arc;
119/// use std::time::Duration;
120/// use dynomite::core::task::task_register;
121///
122/// let rt = tokio::runtime::Runtime::new().unwrap();
123/// rt.block_on(async {
124///     let n = Arc::new(AtomicUsize::new(0));
125///     let nn = n.clone();
126///     let handle = task_register(Duration::from_millis(2), Arc::new(move || {
127///         nn.fetch_add(1, Ordering::Relaxed);
128///     }));
129///     tokio::time::sleep(Duration::from_millis(15)).await;
130///     handle.cancel();
131/// });
132/// ```
133pub fn task_register(period: Duration, callback: Arc<dyn Fn() + Send + Sync>) -> TaskHandle {
134    let token = CancellationToken::new();
135    let child = token.clone();
136    tokio::spawn(async move {
137        let mut interval = tokio::time::interval(period);
138        // Skip the immediate-fire first tick that `tokio::time::interval`
139        // produces by default so the callback first fires after
140        // `timeout` ms, not at registration time.
141        interval.tick().await;
142        loop {
143            tokio::select! {
144                () = child.cancelled() => break,
145                _ = interval.tick() => callback(),
146            }
147        }
148    });
149    TaskHandle { token }
150}
151
152/// Register a one-shot task that fires once after `delay` elapses.
153///
154/// Schedules a single delayed callback. The handle can be cancelled
155/// before the deadline to suppress execution.
156///
157/// # Examples
158///
159/// ```
160/// use std::sync::atomic::{AtomicBool, Ordering};
161/// use std::sync::Arc;
162/// use std::time::Duration;
163/// use dynomite::core::task::task_schedule_once;
164///
165/// let rt = tokio::runtime::Runtime::new().unwrap();
166/// rt.block_on(async {
167///     let fired = Arc::new(AtomicBool::new(false));
168///     let f = fired.clone();
169///     let _h = task_schedule_once(Duration::from_millis(5), Box::new(move || {
170///         f.store(true, Ordering::Relaxed);
171///     }));
172///     tokio::time::sleep(Duration::from_millis(20)).await;
173///     assert!(fired.load(Ordering::Relaxed));
174/// });
175/// ```
176pub fn task_schedule_once(delay: Duration, callback: Box<dyn FnOnce() + Send>) -> TaskHandle {
177    let token = CancellationToken::new();
178    let child = token.clone();
179    tokio::spawn(async move {
180        tokio::select! {
181            () = child.cancelled() => {}
182            () = tokio::time::sleep(delay) => {
183                callback();
184            }
185        }
186    });
187    TaskHandle { token }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::sync::atomic::{AtomicUsize, Ordering};
193    use std::sync::Arc;
194    use std::time::Duration;
195
196    use super::*;
197
198    #[tokio::test(start_paused = true)]
199    async fn periodic_task_fires_multiple_times() {
200        let n = Arc::new(AtomicUsize::new(0));
201        let nn = n.clone();
202        let handle = task_register(
203            Duration::from_millis(5),
204            Arc::new(move || {
205                nn.fetch_add(1, Ordering::Relaxed);
206            }),
207        );
208        // Yield once so the spawned task gets polled and its
209        // interval is registered with the paused clock before
210        // the first advance.
211        tokio::task::yield_now().await;
212        // tokio::time is paused; advance() drives the timer
213        // deterministically without depending on wall-clock
214        // scheduling, eliminating CI flake.
215        for _ in 0..8 {
216            tokio::time::advance(Duration::from_millis(5)).await;
217            tokio::task::yield_now().await;
218        }
219        handle.cancel();
220        let after_cancel = n.load(Ordering::Relaxed);
221        assert!(after_cancel >= 2, "expected >=2 fires, got {after_cancel}");
222        tokio::time::advance(Duration::from_millis(20)).await;
223        tokio::task::yield_now().await;
224        // No fires after cancel.
225        let final_count = n.load(Ordering::Relaxed);
226        assert!(
227            final_count <= after_cancel + 1,
228            "task fired after cancel: before={after_cancel} after={final_count}"
229        );
230    }
231
232    #[tokio::test]
233    async fn cancel_before_first_tick_suppresses_callback() {
234        let n = Arc::new(AtomicUsize::new(0));
235        let nn = n.clone();
236        let handle = task_register(
237            Duration::from_millis(50),
238            Arc::new(move || {
239                nn.fetch_add(1, Ordering::Relaxed);
240            }),
241        );
242        handle.cancel();
243        tokio::time::sleep(Duration::from_millis(80)).await;
244        assert_eq!(n.load(Ordering::Relaxed), 0);
245    }
246
247    #[tokio::test]
248    async fn one_shot_fires_exactly_once() {
249        let n = Arc::new(AtomicUsize::new(0));
250        let nn = n.clone();
251        let _handle = task_schedule_once(
252            Duration::from_millis(5),
253            Box::new(move || {
254                nn.fetch_add(1, Ordering::Relaxed);
255            }),
256        );
257        tokio::time::sleep(Duration::from_millis(40)).await;
258        assert_eq!(n.load(Ordering::Relaxed), 1);
259    }
260
261    #[tokio::test]
262    async fn one_shot_can_be_cancelled() {
263        let n = Arc::new(AtomicUsize::new(0));
264        let nn = n.clone();
265        let handle = task_schedule_once(
266            Duration::from_millis(50),
267            Box::new(move || {
268                nn.fetch_add(1, Ordering::Relaxed);
269            }),
270        );
271        handle.cancel();
272        tokio::time::sleep(Duration::from_millis(80)).await;
273        assert_eq!(n.load(Ordering::Relaxed), 0);
274        assert!(handle.is_cancelled());
275    }
276}