1use chrono::{Datelike, Timelike};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, RwLock};
14use std::thread::JoinHandle;
15use std::time::Duration;
16
17pub mod advanced;
18pub mod scheduler;
19
20pub use scheduler::{CounterJobHandler, JobHandler, RecordingJobHandler};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ScheduledTask {
24 pub id: String,
25 pub name: String,
26 pub cron_expr: String,
27 pub callback: String,
28 pub metadata: HashMap<String, serde_json::Value>,
29 pub enabled: bool,
30}
31
32impl ScheduledTask {
33 pub fn new(
34 id: impl Into<String>,
35 name: impl Into<String>,
36 cron_expr: impl Into<String>,
37 ) -> Self {
38 Self {
39 id: id.into(),
40 name: name.into(),
41 cron_expr: cron_expr.into(),
42 callback: String::new(),
43 metadata: HashMap::new(),
44 enabled: true,
45 }
46 }
47
48 pub fn with_callback(mut self, callback: impl Into<String>) -> Self {
49 self.callback = callback.into();
50 self
51 }
52
53 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
54 self.metadata.insert(key.into(), value);
55 self
56 }
57
58 pub fn disable(mut self) -> Self {
59 self.enabled = false;
60 self
61 }
62}
63
64pub trait Scheduler: Send + Sync {
65 fn schedule(&self, task: ScheduledTask) -> Result<(), SchedulerError>;
66 fn cancel(&self, task_id: &str) -> Result<(), SchedulerError>;
67 fn pause(&self, task_id: &str) -> Result<(), SchedulerError>;
68 fn resume(&self, task_id: &str) -> Result<(), SchedulerError>;
69 fn list_tasks(&self) -> Vec<ScheduledTask>;
70}
71
72pub struct CronScheduler {
73 tasks: Arc<RwLock<HashMap<String, ScheduledTask>>>,
74 handlers: Arc<RwLock<HashMap<String, Arc<dyn JobHandler>>>>,
75 stop_flag: Arc<AtomicBool>,
76 worker: RwLock<Option<JoinHandle<()>>>,
77}
78
79impl CronScheduler {
80 pub fn new() -> Self {
81 Self {
82 tasks: Arc::new(RwLock::new(HashMap::new())),
83 handlers: Arc::new(RwLock::new(HashMap::new())),
84 stop_flag: Arc::new(AtomicBool::new(false)),
85 worker: RwLock::new(None),
86 }
87 }
88
89 pub fn parse_cron(&self, expr: &str) -> Result<CronExpr, SchedulerError> {
90 let parts: Vec<&str> = expr.split_whitespace().collect();
91 if parts.len() != 5 {
92 return Err(SchedulerError::InvalidCronExpr(format!(
93 "Expected 5 fields, got {}",
94 parts.len()
95 )));
96 }
97
98 Ok(CronExpr {
99 second: parts[0].to_string(),
100 minute: parts[1].to_string(),
101 hour: parts[2].to_string(),
102 day_of_month: parts[3].to_string(),
103 month: parts[4].to_string(),
104 })
105 }
106
107 pub fn next_run_time(
108 &self,
109 expr: &str,
110 from: chrono::DateTime<chrono::Utc>,
111 ) -> Result<chrono::DateTime<chrono::Utc>, SchedulerError> {
112 let parsed = self.parse_cron(expr)?;
113
114 let needs_second_precision = !matches!(parsed.second.as_str(), "*" | "0");
118
119 if !needs_second_precision {
120 let mut next = align_to_next_minute_boundary(from);
123 for _ in 0..525_600 {
124 if self.matches_cron(&parsed, next) {
125 return Ok(next);
126 }
127 next += chrono::Duration::minutes(1);
128 }
129 } else {
130 let seconds = self.parse_field_values(&parsed.second, 0, 59)?;
132 let mut minute_start = from
134 .with_second(0)
135 .and_then(|d| d.with_nanosecond(0))
136 .unwrap_or(from);
137
138 for _ in 0..525_600 {
139 if self.matches_cron_ignoring_second(&parsed, minute_start) {
141 for &sec in &seconds {
143 let candidate = minute_start
144 .with_second(sec)
145 .and_then(|d| d.with_nanosecond(0))
146 .unwrap_or(minute_start);
147 if candidate > from {
148 return Ok(candidate);
149 }
150 }
151 }
152 minute_start += chrono::Duration::minutes(1);
153 }
154 }
155
156 Err(SchedulerError::NoNextRunTime(
157 "No next run time found within 365 days".to_string(),
158 ))
159 }
160
161 fn matches_cron(&self, expr: &CronExpr, dt: chrono::DateTime<chrono::Utc>) -> bool {
162 self.field_matches(&expr.second, dt.naive_utc().second())
163 && self.field_matches(&expr.minute, dt.naive_utc().minute())
164 && self.field_matches(&expr.hour, dt.naive_utc().hour())
165 && self.field_matches(&expr.day_of_month, dt.naive_utc().day())
166 && self.field_matches(&expr.month, dt.naive_utc().month())
167 }
168
169 fn matches_cron_ignoring_second(
172 &self,
173 expr: &CronExpr,
174 dt: chrono::DateTime<chrono::Utc>,
175 ) -> bool {
176 self.field_matches(&expr.minute, dt.naive_utc().minute())
177 && self.field_matches(&expr.hour, dt.naive_utc().hour())
178 && self.field_matches(&expr.day_of_month, dt.naive_utc().day())
179 && self.field_matches(&expr.month, dt.naive_utc().month())
180 }
181
182 fn parse_field_values(
185 &self,
186 field: &str,
187 min: u32,
188 max: u32,
189 ) -> Result<Vec<u32>, SchedulerError> {
190 let mut values = Vec::new();
191 if field == "*" {
192 for v in min..=max {
193 values.push(v);
194 }
195 return Ok(values);
196 }
197 for part in field.split(',') {
198 let part = part.trim();
199 if part.contains('/') {
200 let parts: Vec<&str> = part.split('/').collect();
201 if parts.len() != 2 {
202 return Err(SchedulerError::InvalidCronExpr(format!(
203 "Invalid step field: {}",
204 field
205 )));
206 }
207 let step: u32 = parts[1].parse().map_err(|_| {
208 SchedulerError::InvalidCronExpr(format!("Invalid step value: {}", parts[1]))
209 })?;
210 if step == 0 {
211 return Err(SchedulerError::InvalidCronExpr(
212 "Step value cannot be 0".to_string(),
213 ));
214 }
215 let range_part = parts[0];
216 let (start, end) = if range_part == "*" {
217 (min, max)
218 } else if range_part.contains('-') {
219 let range_parts: Vec<&str> = range_part.split('-').collect();
220 if range_parts.len() != 2 {
221 return Err(SchedulerError::InvalidCronExpr(format!(
222 "Invalid range: {}",
223 range_part
224 )));
225 }
226 let s: u32 = range_parts[0].trim().parse().map_err(|_| {
227 SchedulerError::InvalidCronExpr(format!(
228 "Invalid range start: {}",
229 range_parts[0]
230 ))
231 })?;
232 let e: u32 = range_parts[1].trim().parse().map_err(|_| {
233 SchedulerError::InvalidCronExpr(format!(
234 "Invalid range end: {}",
235 range_parts[1]
236 ))
237 })?;
238 (s, e)
239 } else {
240 let s: u32 = range_part.parse().map_err(|_| {
241 SchedulerError::InvalidCronExpr(format!("Invalid value: {}", range_part))
242 })?;
243 (s, max)
244 };
245 let mut v = start;
246 while v <= end {
247 values.push(v);
248 v = v.saturating_add(step);
249 }
250 } else if part.contains('-') {
251 let parts: Vec<&str> = part.split('-').collect();
252 if parts.len() != 2 {
253 return Err(SchedulerError::InvalidCronExpr(format!(
254 "Invalid range: {}",
255 part
256 )));
257 }
258 let start: u32 = parts[0].trim().parse().map_err(|_| {
259 SchedulerError::InvalidCronExpr(format!("Invalid range start: {}", parts[0]))
260 })?;
261 let end: u32 = parts[1].trim().parse().map_err(|_| {
262 SchedulerError::InvalidCronExpr(format!("Invalid range end: {}", parts[1]))
263 })?;
264 for v in start..=end {
265 values.push(v);
266 }
267 } else {
268 let v: u32 = part.parse().map_err(|_| {
269 SchedulerError::InvalidCronExpr(format!("Invalid value: {}", part))
270 })?;
271 values.push(v);
272 }
273 }
274 Ok(values)
275 }
276
277 fn field_matches(&self, field: &str, value: u32) -> bool {
278 if field == "*" {
279 return true;
280 }
281 if field.contains(',') {
282 return field
283 .split(',')
284 .any(|v| v.trim().parse::<u32>().is_ok_and(|n| n == value));
285 }
286 if field.contains('-') {
287 let parts: Vec<&str> = field.split('-').collect();
288 if parts.len() == 2 {
289 let start: u32 = parts[0].trim().parse().unwrap_or(0);
290 let end: u32 = parts[1].trim().parse().unwrap_or(0);
291 return value >= start && value <= end;
292 }
293 }
294 if field.contains('/') {
295 let parts: Vec<&str> = field.split('/').collect();
296 if parts.len() == 2 {
297 let step: u32 = parts[1].parse().unwrap_or(1);
298 return value.is_multiple_of(step);
299 }
300 }
301 field.parse::<u32>().is_ok_and(|n| n == value)
302 }
303
304 pub fn register_handler(&self, task_id: impl Into<String>, handler: Arc<dyn JobHandler>) {
308 let mut handlers = self
309 .handlers
310 .write()
311 .map_err(|e| SchedulerError::Internal(e.to_string()))
312 .unwrap();
313 handlers.insert(task_id.into(), handler);
314 }
315
316 pub fn try_fire_due(&self, now: chrono::DateTime<chrono::Utc>) -> usize {
321 let due: Vec<(ScheduledTask, Option<Arc<dyn JobHandler>>)> = {
322 let tasks = self
323 .tasks
324 .read()
325 .map_err(|e| SchedulerError::Internal(e.to_string()));
326 let handlers = self
327 .handlers
328 .read()
329 .map_err(|e| SchedulerError::Internal(e.to_string()));
330 let (Ok(tasks), Ok(handlers)) = (tasks, handlers) else {
331 return 0;
332 };
333
334 tasks
335 .values()
336 .filter(|t| t.enabled)
337 .filter_map(|t| {
338 let parsed = self.parse_cron(&t.cron_expr).ok()?;
339 if self.matches_cron(&parsed, now) {
340 Some((t.clone(), handlers.get(&t.id).cloned()))
341 } else {
342 None
343 }
344 })
345 .collect()
346 };
347
348 let mut fired = 0usize;
349 for (task, handler) in due {
350 if let Some(handler) = handler {
351 if handler.handle(&task).is_ok() {
352 fired += 1;
353 }
354 } else {
355 fired += 1;
358 }
359 }
360 fired
361 }
362
363 pub fn start(&self, tick_ms: u64) -> Result<(), SchedulerError> {
370 let mut worker = self
371 .worker
372 .write()
373 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
374 if worker.is_some() {
375 return Err(SchedulerError::Internal(
376 "scheduler already running".to_string(),
377 ));
378 }
379
380 self.stop_flag.store(false, Ordering::SeqCst);
381 let stop_flag = self.stop_flag.clone();
382 let tasks = self.tasks.clone();
383 let handlers = self.handlers.clone();
384
385 let handle = std::thread::spawn(move || {
386 while !stop_flag.load(Ordering::SeqCst) {
387 std::thread::sleep(Duration::from_millis(tick_ms.max(1)));
388 if stop_flag.load(Ordering::SeqCst) {
389 break;
390 }
391 let now = chrono::Utc::now();
392 let scheduler = CronScheduler {
393 tasks: tasks.clone(),
394 handlers: handlers.clone(),
395 stop_flag: stop_flag.clone(),
396 worker: RwLock::new(None),
397 };
398 let _ = scheduler.try_fire_due(now);
399 }
400 });
401
402 *worker = Some(handle);
403 Ok(())
404 }
405
406 pub fn stop(&self) -> Result<(), SchedulerError> {
409 let mut worker = self
410 .worker
411 .write()
412 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
413 if let Some(handle) = worker.take() {
414 self.stop_flag.store(true, Ordering::SeqCst);
415 drop(worker);
419 handle
420 .join()
421 .map_err(|_| SchedulerError::Internal("worker thread panicked".to_string()))?;
422 }
423 Ok(())
424 }
425
426 pub fn is_running(&self) -> bool {
428 let worker = self
429 .worker
430 .read()
431 .map_err(|e| SchedulerError::Internal(e.to_string()));
432 match worker {
433 Ok(w) => w.is_some(),
434 Err(_) => false,
435 }
436 }
437}
438
439impl Default for CronScheduler {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445fn align_to_next_minute_boundary(
453 dt: chrono::DateTime<chrono::Utc>,
454) -> chrono::DateTime<chrono::Utc> {
455 use chrono::Timelike;
456 let truncated = dt
460 .with_second(0)
461 .and_then(|d| d.with_nanosecond(0))
462 .unwrap_or(dt);
463 truncated + chrono::Duration::minutes(1)
464}
465
466#[derive(Debug, Clone)]
467pub struct CronExpr {
468 pub second: String,
469 pub minute: String,
470 pub hour: String,
471 pub day_of_month: String,
472 pub month: String,
473}
474
475impl Scheduler for CronScheduler {
476 fn schedule(&self, task: ScheduledTask) -> Result<(), SchedulerError> {
477 if task.cron_expr.is_empty() {
478 return Err(SchedulerError::InvalidCronExpr(
479 "Cron expression cannot be empty".to_string(),
480 ));
481 }
482
483 self.parse_cron(&task.cron_expr)?;
484
485 let mut tasks = self
486 .tasks
487 .write()
488 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
489 tasks.insert(task.id.clone(), task);
490 Ok(())
491 }
492
493 fn cancel(&self, task_id: &str) -> Result<(), SchedulerError> {
494 let mut tasks = self
495 .tasks
496 .write()
497 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
498 tasks
499 .remove(task_id)
500 .ok_or_else(|| SchedulerError::TaskNotFound(task_id.to_string()))?;
501 Ok(())
502 }
503
504 fn pause(&self, task_id: &str) -> Result<(), SchedulerError> {
505 let mut tasks = self
506 .tasks
507 .write()
508 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
509 let task = tasks
510 .get_mut(task_id)
511 .ok_or_else(|| SchedulerError::TaskNotFound(task_id.to_string()))?;
512 task.enabled = false;
513 Ok(())
514 }
515
516 fn resume(&self, task_id: &str) -> Result<(), SchedulerError> {
517 let mut tasks = self
518 .tasks
519 .write()
520 .map_err(|e| SchedulerError::Internal(e.to_string()))?;
521 let task = tasks
522 .get_mut(task_id)
523 .ok_or_else(|| SchedulerError::TaskNotFound(task_id.to_string()))?;
524 task.enabled = true;
525 Ok(())
526 }
527
528 fn list_tasks(&self) -> Vec<ScheduledTask> {
529 let tasks = self
530 .tasks
531 .read()
532 .map_err(|e| SchedulerError::Internal(e.to_string()))
533 .unwrap();
534 tasks.values().cloned().collect()
535 }
536}
537
538#[derive(Debug, thiserror::Error)]
539pub enum SchedulerError {
540 #[error("Task not found: {0}")]
541 TaskNotFound(String),
542 #[error("Invalid cron expression: {0}")]
543 InvalidCronExpr(String),
544 #[error("Failed to compute next run time: {0}")]
545 NoNextRunTime(String),
546 #[error("Scheduler error: {0}")]
547 Internal(String),
548}
549
550impl From<chrono::ParseError> for SchedulerError {
551 fn from(e: chrono::ParseError) -> Self {
552 SchedulerError::InvalidCronExpr(e.to_string())
553 }
554}
555
556impl serde::Serialize for SchedulerError {
557 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
558 where
559 S: serde::Serializer,
560 {
561 serializer.serialize_str(&self.to_string())
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_scheduled_task_new() {
571 let task = ScheduledTask::new("task1", "Test Task", "0 * * * *");
572 assert_eq!(task.id, "task1");
573 assert_eq!(task.name, "Test Task");
574 assert_eq!(task.cron_expr, "0 * * * *");
575 assert!(task.enabled);
576 }
577
578 #[test]
579 fn test_scheduled_task_with_callback() {
580 let task = ScheduledTask::new("task1", "Test", "* * * * *").with_callback("my_callback");
581 assert_eq!(task.callback, "my_callback");
582 }
583
584 #[test]
585 fn test_scheduled_task_disable() {
586 let task = ScheduledTask::new("task1", "Test", "* * * * *").disable();
587 assert!(!task.enabled);
588 }
589
590 #[test]
591 fn test_cron_parse() {
592 let scheduler = CronScheduler::new();
593 let result = scheduler.parse_cron("0 * * * *");
594 assert!(result.is_ok());
595 let expr = result.unwrap();
596 assert_eq!(expr.second, "0");
597 assert_eq!(expr.minute, "*");
598 }
599
600 #[test]
601 fn test_cron_parse_invalid() {
602 let scheduler = CronScheduler::new();
603 let result = scheduler.parse_cron("invalid");
604 assert!(result.is_err());
605 }
606
607 #[test]
608 fn test_cron_field_matches_star() {
609 let scheduler = CronScheduler::new();
610 assert!(scheduler.field_matches("*", 5));
611 assert!(scheduler.field_matches("*", 0));
612 assert!(scheduler.field_matches("*", 59));
613 }
614
615 #[test]
616 fn test_cron_field_matches_exact() {
617 let scheduler = CronScheduler::new();
618 assert!(scheduler.field_matches("5", 5));
619 assert!(!scheduler.field_matches("5", 6));
620 }
621
622 #[test]
623 fn test_cron_field_matches_range() {
624 let scheduler = CronScheduler::new();
625 assert!(scheduler.field_matches("1-5", 3));
626 assert!(!scheduler.field_matches("1-5", 7));
627 }
628
629 #[test]
630 fn test_cron_field_matches_list() {
631 let scheduler = CronScheduler::new();
632 assert!(scheduler.field_matches("1,3,5", 3));
633 assert!(!scheduler.field_matches("1,3,5", 2));
634 }
635
636 #[test]
637 fn test_cron_field_matches_step() {
638 let scheduler = CronScheduler::new();
639 assert!(scheduler.field_matches("*/5", 10));
640 assert!(scheduler.field_matches("*/5", 15));
641 assert!(!scheduler.field_matches("*/5", 7));
642 }
643
644 #[test]
645 fn test_scheduler_schedule() {
646 let scheduler = CronScheduler::new();
647 let task = ScheduledTask::new("task1", "Test", "0 * * * *");
648 let result = scheduler.schedule(task);
649 assert!(result.is_ok());
650 }
651
652 #[test]
653 fn test_scheduler_schedule_invalid_cron() {
654 let scheduler = CronScheduler::new();
655 let task = ScheduledTask::new("task1", "Test", "invalid");
656 let result = scheduler.schedule(task);
657 assert!(result.is_err());
658 }
659
660 #[test]
661 fn test_scheduler_cancel() {
662 let scheduler = CronScheduler::new();
663 let task = ScheduledTask::new("task1", "Test", "0 * * * *");
664 scheduler.schedule(task).unwrap();
665
666 let result = scheduler.cancel("task1");
667 assert!(result.is_ok());
668 }
669
670 #[test]
671 fn test_scheduler_cancel_not_found() {
672 let scheduler = CronScheduler::new();
673 let result = scheduler.cancel("nonexistent");
674 assert!(result.is_err());
675 }
676
677 #[test]
678 fn test_scheduler_pause_resume() {
679 let scheduler = CronScheduler::new();
680 let task = ScheduledTask::new("task1", "Test", "0 * * * *");
681 scheduler.schedule(task).unwrap();
682
683 scheduler.pause("task1").unwrap();
684 let tasks = scheduler.list_tasks();
685 assert!(!tasks[0].enabled);
686
687 scheduler.resume("task1").unwrap();
688 let tasks = scheduler.list_tasks();
689 assert!(tasks[0].enabled);
690 }
691
692 #[test]
693 fn test_scheduler_list_tasks() {
694 let scheduler = CronScheduler::new();
695 scheduler
696 .schedule(ScheduledTask::new("t1", "Task 1", "0 * * * *"))
697 .unwrap();
698 scheduler
699 .schedule(ScheduledTask::new("t2", "Task 2", "0 * * * *"))
700 .unwrap();
701
702 let tasks = scheduler.list_tasks();
703 assert_eq!(tasks.len(), 2);
704 }
705
706 #[test]
707 fn test_next_run_time_finds_next_minute_match() {
708 let scheduler = CronScheduler::new();
711 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
712 .unwrap()
713 .with_timezone(&chrono::Utc);
714 let next = scheduler.next_run_time("* * * * *", from).unwrap();
715 assert_eq!(next, from + chrono::Duration::minutes(1));
716 }
717
718 #[test]
719 fn test_next_run_time_finds_hourly_match() {
720 let scheduler = CronScheduler::new();
726 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:30Z")
727 .unwrap()
728 .with_timezone(&chrono::Utc);
729 let next = scheduler.next_run_time("0 * * * *", from).unwrap();
730 assert_eq!(next, from + chrono::Duration::seconds(30));
732 }
733
734 #[test]
735 fn test_next_run_time_finds_daily_match_far_ahead() {
736 let scheduler = CronScheduler::new();
742 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:01:00Z")
743 .unwrap()
744 .with_timezone(&chrono::Utc);
745 let next = scheduler.next_run_time("0 0 1 1 *", from);
746 assert!(
747 next.is_ok(),
748 "should find next run within 365 days, got: {:?}",
749 next
750 );
751 }
752
753 #[test]
754 fn test_try_fire_due_fires_matching_task_with_handler() {
755 let scheduler = CronScheduler::new();
756 let task = ScheduledTask::new("t1", "Test", "* * * * *");
757 scheduler.schedule(task).unwrap();
758
759 let handler = Arc::new(CounterJobHandler::new());
760 let counter = handler.counter();
761 scheduler.register_handler("t1", handler);
762
763 let now = chrono::Utc::now();
764 let fired = scheduler.try_fire_due(now);
765 assert_eq!(fired, 1);
766 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
767
768 scheduler.try_fire_due(now);
770 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 2);
771 }
772
773 #[test]
774 fn test_try_fire_due_skips_non_matching_task() {
775 let scheduler = CronScheduler::new();
776 let task = ScheduledTask::new("never", "Test", "99 * * * *");
779 scheduler.schedule(task).unwrap();
780
781 let handler = Arc::new(CounterJobHandler::new());
782 let counter = handler.counter();
783 scheduler.register_handler("never", handler);
784
785 let now = chrono::Utc::now();
786 let fired = scheduler.try_fire_due(now);
787 assert_eq!(fired, 0);
788 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 0);
789 }
790
791 #[test]
792 fn test_try_fire_due_skips_paused_task() {
793 let scheduler = CronScheduler::new();
794 scheduler
795 .schedule(ScheduledTask::new("t1", "Test", "* * * * *"))
796 .unwrap();
797 scheduler.pause("t1").unwrap();
798
799 let handler = Arc::new(CounterJobHandler::new());
800 let counter = handler.counter();
801 scheduler.register_handler("t1", handler);
802
803 let now = chrono::Utc::now();
804 let fired = scheduler.try_fire_due(now);
805 assert_eq!(fired, 0);
806 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 0);
807 }
808
809 #[test]
810 fn test_start_stop_background_thread() {
811 let scheduler = CronScheduler::new();
812 scheduler
813 .schedule(ScheduledTask::new("t1", "Test", "* * * * *"))
814 .unwrap();
815 let handler = Arc::new(CounterJobHandler::new());
816 let counter = handler.counter();
817 scheduler.register_handler("t1", handler);
818
819 assert!(!scheduler.is_running());
820 scheduler.start(50).unwrap();
821 assert!(scheduler.is_running());
822
823 std::thread::sleep(Duration::from_millis(300));
825 assert!(
826 counter.load(std::sync::atomic::Ordering::SeqCst) >= 1,
827 "expected the background thread to fire the handler at least once"
828 );
829
830 scheduler.stop().unwrap();
831 assert!(!scheduler.is_running());
832
833 let after_stop = counter.load(std::sync::atomic::Ordering::SeqCst);
835 std::thread::sleep(Duration::from_millis(200));
838 assert_eq!(
839 counter.load(std::sync::atomic::Ordering::SeqCst),
840 after_stop,
841 "counter should not change after stop()"
842 );
843 }
844
845 #[test]
846 fn test_start_twice_errors() {
847 let scheduler = CronScheduler::new();
848 scheduler.start(1000).unwrap();
849 let second = scheduler.start(1000);
850 assert!(second.is_err());
851 scheduler.stop().unwrap();
852 }
853
854 #[test]
855 fn test_stop_when_not_running_is_noop() {
856 let scheduler = CronScheduler::new();
857 assert!(scheduler.stop().is_ok());
858 }
859
860 #[test]
861 fn test_recording_handler_with_try_fire_due() {
862 let scheduler = CronScheduler::new();
863 scheduler
864 .schedule(ScheduledTask::new("a", "Task A", "* * * * *"))
865 .unwrap();
866 scheduler
867 .schedule(ScheduledTask::new("b", "Task B", "99 * * * *"))
868 .unwrap();
869 scheduler
870 .schedule(ScheduledTask::new("c", "Task C", "* * * * *"))
871 .unwrap();
872
873 let handler = Arc::new(RecordingJobHandler::new());
874 scheduler.register_handler("a", handler.clone());
875 scheduler.register_handler("b", handler.clone());
876 scheduler.register_handler("c", handler.clone());
877
878 let now = chrono::Utc::now();
879 let fired = scheduler.try_fire_due(now);
880 assert_eq!(fired, 2); let ids = handler.handled_ids();
882 assert!(ids.contains(&"a".to_string()));
883 assert!(ids.contains(&"c".to_string()));
884 assert!(!ids.contains(&"b".to_string()));
885 }
886
887 #[test]
890 fn test_next_run_time_second_precision_single_value() {
891 let scheduler = CronScheduler::new();
894 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
895 .unwrap()
896 .with_timezone(&chrono::Utc);
897 let next = scheduler.next_run_time("30 * * * *", from);
898 assert!(
899 next.is_ok(),
900 "should find next run for second=30 cron, got: {:?}",
901 next
902 );
903 assert_eq!(next.unwrap(), from + chrono::Duration::seconds(30));
904 }
905
906 #[test]
907 fn test_next_run_time_second_precision_next_minute() {
908 let scheduler = CronScheduler::new();
911 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:45Z")
912 .unwrap()
913 .with_timezone(&chrono::Utc);
914 let next = scheduler.next_run_time("30 * * * *", from).unwrap();
915 assert_eq!(next, from + chrono::Duration::seconds(45));
916 }
917
918 #[test]
919 fn test_next_run_time_second_range() {
920 let scheduler = CronScheduler::new();
923 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
924 .unwrap()
925 .with_timezone(&chrono::Utc);
926 let next = scheduler.next_run_time("10-12 * * * *", from).unwrap();
927 assert_eq!(next, from + chrono::Duration::seconds(10));
928 }
929
930 #[test]
931 fn test_next_run_time_second_list_skips_past() {
932 let scheduler = CronScheduler::new();
935 let from = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:15Z")
936 .unwrap()
937 .with_timezone(&chrono::Utc);
938 let next = scheduler.next_run_time("10,20,30 * * * *", from).unwrap();
939 assert_eq!(next, from + chrono::Duration::seconds(5));
940 }
941}