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}