Skip to main content

sz_orm_scheduler/
lib.rs

1//! # SZ-ORM Scheduler — 定时任务调度器
2//!
3//! 提供基于 cron 表达式的定时任务调度,支持任务启停、状态管理与回调执行。
4//!
5//! ## 主要模块
6//!
7//! - [`scheduler`] — 任务处理器 trait 与测试辅助实现
8
9use 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        // 判断 second 字段是否需要精确扫描(非 "*" 且非 "0")
115        // second="*" 或 "0" 时,对齐到分钟边界(second=0)后按分钟扫描即可
116        // second 为其他值(如 "30"、"10-12"、"10,20,30")时,需在匹配分钟内找具体 second
117        let needs_second_precision = !matches!(parsed.second.as_str(), "*" | "0");
118
119        if !needs_second_precision {
120            // second 字段是 "*" 或 "0":保留原逻辑,按分钟扫描
121            // 对齐到下一分钟边界(second=0),扫描 525600 分钟(365 天)
122            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            // second 字段包含非 0 值:按分钟扫描,在匹配分钟内找具体 second
131            let seconds = self.parse_field_values(&parsed.second, 0, 59)?;
132            // 从 from 截断到当前分钟开始(保留当前分钟内未来 second 的可能性)
133            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                // 检查 minute/hour/day/month 是否匹配(不检查 second)
140                if self.matches_cron_ignoring_second(&parsed, minute_start) {
141                    // 在该分钟内找第一个 > from 的 second
142                    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    /// 检查 minute/hour/day/month 是否匹配(不检查 second)
170    /// 用于秒级精确扫描时,先筛选出 minute/hour/day/month 匹配的分钟
171    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    /// 将 cron 字段解析为有序的数值列表
183    /// 支持:* / 单值 / 逗号列表 / 范围 / 步长
184    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    /// Registers a [`JobHandler`] for the given task id. When the scheduler
305    /// fires a matching task, it looks up the handler by task id. If no
306    /// handler is registered, the task is skipped silently.
307    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    /// Fires every enabled task whose cron expression matches `now`. Returns
317    /// the number of tasks that fired (and whose handler, if any, returned
318    /// `Ok(())`). Errors from individual handlers are recorded but do not
319    /// abort iteration.
320    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                // No handler registered: still count as "fired" so tests can
356                // observe cron matching independently of handler logic.
357                fired += 1;
358            }
359        }
360        fired
361    }
362
363    /// Starts a background worker thread that wakes up every `tick_ms`
364    /// milliseconds, queries the current UTC time, and invokes
365    /// [`try_fire_due`]. Calling `start` while a worker is already running
366    /// returns an error.
367    ///
368    /// [`try_fire_due`]: CronScheduler::try_fire_due
369    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    /// Stops the background worker thread and waits for it to exit. If no
407    /// worker is running, this is a no-op.
408    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 the lock before joining to avoid a deadlock if the worker
416            // ever needs to read `worker` (it doesn't today, but this keeps
417            // the invariant explicit).
418            drop(worker);
419            handle
420                .join()
421                .map_err(|_| SchedulerError::Internal("worker thread panicked".to_string()))?;
422        }
423        Ok(())
424    }
425
426    /// Returns `true` if a background worker is currently running.
427    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
445/// Returns the next whole-minute boundary strictly after `dt`, with
446/// `second = 0` and `nanosecond = 0`.
447///
448/// Examples:
449/// - `00:00:00` → `00:01:00`
450/// - `00:00:30` → `00:01:00`
451/// - `00:01:45.500` → `00:02:00`
452fn align_to_next_minute_boundary(
453    dt: chrono::DateTime<chrono::Utc>,
454) -> chrono::DateTime<chrono::Utc> {
455    use chrono::Timelike;
456    // Truncate to current minute, then advance by one minute so we never
457    // report `dt` itself as the next run time (callers expect "next" to
458    // mean strictly after `dt`).
459    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        // `* * * * *` matches every minute, so next_run_time should return
709        // the next minute after `from`.
710        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        // `0 * * * *` matches second=0, every minute/hour/day/month - i.e.
721        // every minute where second is 0. With 5-field cron (where the first
722        // field is `second`), `0 * * * *` matches every minute when second=0.
723        // Since we scan minute-by-minute, second is always 0 at scan points,
724        // so the first scan iteration should match.
725        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        // from is 00:00:30; next minute is 00:01:00 (second=0, matches).
731        assert_eq!(next, from + chrono::Duration::seconds(30));
732    }
733
734    #[test]
735    fn test_next_run_time_finds_daily_match_far_ahead() {
736        // Cron `0 0 1 1 *` matches only at 00:00:00 on Jan 1 of any year.
737        // Starting from 2024-01-01 00:01:00, the next match is 2025-01-01.
738        // Before the fix, scanning only 365 minutes (~6 hours) ahead would
739        // fail to find this match. The fixed scan window is 525,600 minutes
740        // (~365 days), which is enough to find it.
741        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        // Fire again to make sure counter accumulates.
769        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        // Cron `99 * * * *` is technically parseable (field "99" parses as
777        // u32=99), but no real time has second=99 so it never matches.
778        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        // Wait long enough for at least one tick (50ms) + jitter.
824        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        // Snapshot the counter after stopping.
834        let after_stop = counter.load(std::sync::atomic::Ordering::SeqCst);
835        // Wait a bit more to ensure the worker has actually exited and is no
836        // longer invoking the handler.
837        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); // Only "a" and "c" match.
881        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    // ===== TDD RED:秒级 cron 支持测试(bug 修复前应失败) =====
888
889    #[test]
890    fn test_next_run_time_second_precision_single_value() {
891        // `30 * * * *` 表示每分钟的 30 秒触发
892        // from=00:00:00,下一个匹配应为 00:00:30(同一分钟内的 30 秒)
893        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        // `30 * * * *` from=00:00:45,当前分钟内 30 秒已过
909        // 下一个匹配应为 00:01:30(下一分钟的 30 秒)
910        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        // `10-12 * * * *` 表示每分钟的 10/11/12 秒触发
921        // from=00:00:00,第一个匹配应为 00:00:10
922        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        // `10,20,30 * * * *` from=00:00:15
933        // 10 秒已过,下一个匹配应为 00:00:20
934        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}