tokio_prompt_orchestrator/
job_scheduler.rs1use std::{
23 collections::HashMap,
24 sync::{
25 atomic::{AtomicU64, Ordering},
26 Arc,
27 },
28 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
29};
30
31use futures::future::BoxFuture;
32use tokio::{sync::Mutex, task::JoinHandle, time::sleep};
33
34#[derive(Debug, Clone)]
38pub enum Schedule {
39 Interval(Duration),
41 Once(Instant),
43 Cron {
45 hour: Option<u8>,
47 minute: Option<u8>,
49 second: Option<u8>,
51 },
52}
53
54impl Schedule {
55 pub fn next_tick(&self, now: Instant) -> Instant {
61 match self {
62 Schedule::Interval(d) => now + *d,
63 Schedule::Once(at) => *at,
64 Schedule::Cron { hour, minute, second } => {
65 cron_next_tick(*hour, *minute, *second, now)
66 }
67 }
68 }
69}
70
71fn cron_next_tick(
74 hour: Option<u8>,
75 minute: Option<u8>,
76 second: Option<u8>,
77 now: Instant,
78) -> Instant {
79 let wall_now = SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .unwrap_or_default()
83 .as_secs();
84
85 let max_search = 24 * 3600_usize;
87
88 for candidate in (wall_now + 1..).take(max_search) {
90 let s = candidate % 60;
92 let m = (candidate / 60) % 60;
93 let h = (candidate / 3600) % 24;
94
95 let h_ok = hour.is_none_or(|hh| hh as u64 == h);
96 let m_ok = minute.is_none_or(|mm| mm as u64 == m);
97 let s_ok = second.is_none_or(|ss| ss as u64 == s);
98
99 if h_ok && m_ok && s_ok {
100 let delta_secs = candidate.saturating_sub(wall_now);
102 return now + Duration::from_secs(delta_secs);
103 }
104 }
105
106 now + Duration::from_secs(86_400)
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct JobId(u64);
115
116impl std::fmt::Display for JobId {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 write!(f, "job:{}", self.0)
119 }
120}
121
122pub type JobHandler = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
126
127#[derive(Debug, Clone)]
131pub struct Job {
132 pub id: JobId,
134 pub name: String,
136 pub schedule: Schedule,
138 pub last_run: Option<Instant>,
140 pub run_count: u64,
142 pub enabled: bool,
144}
145
146impl Job {
147 fn new(id: JobId, name: String, schedule: Schedule) -> Self {
148 Self {
149 id,
150 name,
151 schedule,
152 last_run: None,
153 run_count: 0,
154 enabled: true,
155 }
156 }
157
158 fn next_tick(&self, now: Instant) -> Option<Instant> {
160 match &self.schedule {
161 Schedule::Once(_) if self.run_count > 0 => None, _ => Some(self.schedule.next_tick(self.last_run.unwrap_or(now - Duration::from_secs(1)))),
163 }
164 }
165}
166
167#[derive(Debug, Clone)]
171pub struct JobStats {
172 pub id: JobId,
174 pub name: String,
176 pub run_count: u64,
178 pub last_run: Option<Instant>,
180 pub next_run: Option<Instant>,
182 pub enabled: bool,
184}
185
186type JobMap = HashMap<JobId, (Job, JobHandler)>;
189
190#[derive(Clone)]
195pub struct Scheduler {
196 jobs: Arc<Mutex<JobMap>>,
197 next_id: Arc<AtomicU64>,
198}
199
200impl Default for Scheduler {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl Scheduler {
207 pub fn new() -> Self {
209 Self {
210 jobs: Arc::new(Mutex::new(HashMap::new())),
211 next_id: Arc::new(AtomicU64::new(1)),
212 }
213 }
214
215 pub async fn schedule<F>(&self, name: &str, schedule: Schedule, handler: F) -> JobId
220 where
221 F: Fn() -> BoxFuture<'static, ()> + Send + Sync + 'static,
222 {
223 let id = JobId(self.next_id.fetch_add(1, Ordering::Relaxed));
224 let job = Job::new(id, name.to_string(), schedule);
225 let handler: JobHandler = Arc::new(handler);
226 self.jobs.lock().await.insert(id, (job, handler));
227 id
228 }
229
230 pub async fn cancel(&self, id: JobId) -> bool {
232 self.jobs.lock().await.remove(&id).is_some()
233 }
234
235 pub async fn enable(&self, id: JobId, enabled: bool) {
237 let mut jobs = self.jobs.lock().await;
238 if let Some((job, _)) = jobs.get_mut(&id) {
239 job.enabled = enabled;
240 }
241 }
242
243 pub async fn stats(&self) -> Vec<JobStats> {
245 let now = Instant::now();
246 let jobs = self.jobs.lock().await;
247 jobs.values()
248 .map(|(job, _)| JobStats {
249 id: job.id,
250 name: job.name.clone(),
251 run_count: job.run_count,
252 last_run: job.last_run,
253 next_run: job.next_tick(now),
254 enabled: job.enabled,
255 })
256 .collect()
257 }
258
259 pub async fn run_loop(&self) {
262 const MAX_POLL: Duration = Duration::from_millis(100);
265 const MIN_POLL: Duration = Duration::from_millis(1);
266 let mut wait = MAX_POLL;
267 loop {
268 sleep(wait).await;
269 let now = Instant::now();
270
271 let mut to_fire: Vec<(JobId, JobHandler)> = Vec::new();
273 {
274 let mut jobs = self.jobs.lock().await;
275 for (job, handler) in jobs.values_mut() {
276 if !job.enabled {
277 continue;
278 }
279 let next = match job.next_tick(now) {
280 Some(t) => t,
281 None => continue, };
283 if next <= now {
284 to_fire.push((job.id, Arc::clone(handler)));
285 job.last_run = Some(now);
286 job.run_count += 1;
287 }
288 }
289 }
290
291 for (_id, handler) in to_fire {
293 tokio::spawn(handler());
294 }
295
296 let earliest = {
297 let jobs = self.jobs.lock().await;
298 jobs.values()
299 .filter(|(job, _)| job.enabled)
300 .filter_map(|(job, _)| job.next_tick(now))
301 .min()
302 };
303 wait = earliest
304 .map(|t| t.saturating_duration_since(Instant::now()))
305 .unwrap_or(MAX_POLL)
306 .clamp(MIN_POLL, MAX_POLL);
307 }
308 }
309
310 pub fn start(&self) -> JoinHandle<()> {
312 let scheduler = self.clone();
313 tokio::spawn(async move { scheduler.run_loop().await })
314 }
315}
316
317#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
323 use std::sync::Arc;
324 use tokio::time::sleep;
325
326 #[tokio::test]
327 async fn test_interval_fires_repeatedly() {
328 let counter = Arc::new(AtomicUsize::new(0));
329 let scheduler = Scheduler::new();
330
331 let c = Arc::clone(&counter);
332 scheduler
333 .schedule("counter", Schedule::Interval(Duration::from_millis(10)), move || {
334 let cc = Arc::clone(&c);
335 Box::pin(async move {
336 cc.fetch_add(1, AOrdering::Relaxed);
337 })
338 })
339 .await;
340
341 let handle = scheduler.start();
342 sleep(Duration::from_millis(250)).await;
344 handle.abort();
345
346 let count = counter.load(AOrdering::Relaxed);
347 assert!(count >= 3, "expected >= 3 firings, got {}", count);
348 }
349
350 #[tokio::test]
351 async fn test_cancel_stops_firing() {
352 let counter = Arc::new(AtomicUsize::new(0));
353 let scheduler = Scheduler::new();
354
355 let c = Arc::clone(&counter);
356 let id = scheduler
357 .schedule("cancel-me", Schedule::Interval(Duration::from_millis(10)), move || {
358 let cc = Arc::clone(&c);
359 Box::pin(async move {
360 cc.fetch_add(1, AOrdering::Relaxed);
361 })
362 })
363 .await;
364
365 let handle = scheduler.start();
366 sleep(Duration::from_millis(80)).await;
368 scheduler.cancel(id).await;
369 let count_after_cancel = counter.load(AOrdering::Relaxed);
370 sleep(Duration::from_millis(150)).await;
372 handle.abort();
373
374 let final_count = counter.load(AOrdering::Relaxed);
375 assert_eq!(count_after_cancel, final_count, "job fired after cancel");
376 }
377
378 #[tokio::test]
379 async fn test_disabled_job_skips() {
380 let counter = Arc::new(AtomicUsize::new(0));
381 let scheduler = Scheduler::new();
382
383 let c = Arc::clone(&counter);
384 let id = scheduler
385 .schedule("disabled", Schedule::Interval(Duration::from_millis(10)), move || {
386 let cc = Arc::clone(&c);
387 Box::pin(async move {
388 cc.fetch_add(1, AOrdering::Relaxed);
389 })
390 })
391 .await;
392
393 scheduler.enable(id, false).await;
395
396 let handle = scheduler.start();
397 sleep(Duration::from_millis(150)).await;
398 handle.abort();
399
400 assert_eq!(counter.load(AOrdering::Relaxed), 0, "disabled job must not fire");
401 }
402
403 #[tokio::test]
404 async fn test_once_fires_once() {
405 let counter = Arc::new(AtomicUsize::new(0));
406 let scheduler = Scheduler::new();
407
408 let c = Arc::clone(&counter);
409 scheduler
410 .schedule(
411 "once",
412 Schedule::Once(Instant::now() + Duration::from_millis(30)),
413 move || {
414 let cc = Arc::clone(&c);
415 Box::pin(async move {
416 cc.fetch_add(1, AOrdering::Relaxed);
417 })
418 },
419 )
420 .await;
421
422 let handle = scheduler.start();
423 sleep(Duration::from_millis(250)).await;
424 handle.abort();
425
426 assert_eq!(counter.load(AOrdering::Relaxed), 1, "once job should fire exactly once");
427 }
428
429 #[test]
430 fn test_schedule_next_tick_interval() {
431 let now = Instant::now();
432 let sched = Schedule::Interval(Duration::from_secs(5));
433 let next = sched.next_tick(now);
434 assert!(next > now);
435 assert!(next <= now + Duration::from_secs(6));
436 }
437
438 #[test]
439 fn test_schedule_next_tick_once() {
440 let target = Instant::now() + Duration::from_secs(10);
441 let sched = Schedule::Once(target);
442 assert_eq!(sched.next_tick(Instant::now()), target);
443 }
444
445 #[test]
446 fn test_cron_next_tick_is_future() {
447 let now = Instant::now();
448 let sched = Schedule::Cron { hour: None, minute: None, second: None };
449 let next = sched.next_tick(now);
450 assert!(next > now, "cron next tick should be in the future");
451 }
452}