Skip to main content

sz_rust_cli/cmd/
scheduler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3//
4//! `scheduler:*` 命令 — 接入 sz-orm-scheduler,支持配置文件加载
5//!
6//! ## 命令列表
7//!
8//! - `scheduler:list` — 列出配置文件中定义的调度任务
9//! - `scheduler:run` — 立即执行一次到期任务
10//! - `scheduler:start` — 启动调度器(持续运行)
11//!
12//! ## 配置文件
13//!
14//! 默认读取当前目录下的 `scheduler.toml`,格式:
15//!
16//! ```toml
17//! [[tasks]]
18//! id = "cleanup-logs"
19//! name = "清理日志"
20//! cron = "0 3 * * *"
21//! callback = "demo::cleanup_logs"
22//! enabled = true
23//!
24//! [[tasks]]
25//! id = "sync-data"
26//! name = "数据同步"
27//! cron = "*/30 * * * *"
28//! callback = "demo::sync_data"
29//! ```
30//!
31//! ## PHP 对齐
32//!
33//! PHP ThinkPHP 6 通过 `think\swoole\crontab\Crontab` 注册定时任务。
34//! Rust 端复用 `sz-orm_scheduler::CronScheduler`。
35
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use clap::Subcommand;
40use serde::{Deserialize, Serialize};
41
42use sz_rust_core::orm::scheduler::Scheduler;
43
44use crate::error::CliError;
45
46/// 默认配置文件路径
47const DEFAULT_CONFIG_PATH: &str = "scheduler.toml";
48
49/// `scheduler` 子命令枚举
50#[derive(Subcommand, Debug)]
51pub enum SchedulerCommand {
52    /// 列出配置文件中定义的调度任务
53    #[command(name = "list")]
54    List {
55        /// 配置文件路径(默认 scheduler.toml)
56        #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
57        config: PathBuf,
58    },
59
60    /// 立即执行一次到期任务(对齐 `php think scheduler:run`)
61    #[command(name = "run")]
62    Run {
63        /// 配置文件路径(默认 scheduler.toml)
64        #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
65        config: PathBuf,
66    },
67
68    /// 启动调度器(持续运行,对齐 `php think scheduler:start`)
69    #[command(name = "start")]
70    Start {
71        /// 调度器 tick 间隔(毫秒,默认 1000)
72        #[arg(short = 't', long, default_value = "1000")]
73        tick_ms: u64,
74
75        /// 配置文件路径(默认 scheduler.toml)
76        #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
77        config: PathBuf,
78    },
79}
80
81/// 调度任务配置项(对应 `scheduler.toml` 中的 `[[tasks]]` 段)
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct TaskConfig {
84    /// 任务 ID(唯一标识)
85    pub id: String,
86    /// 任务名称
87    pub name: String,
88    /// cron 表达式(5 字段:second minute hour day month)
89    pub cron: String,
90    /// 回调标识(用于 handler 注册查找)
91    #[serde(default)]
92    pub callback: String,
93    /// 是否启用(默认 true)
94    #[serde(default = "default_enabled")]
95    pub enabled: bool,
96}
97
98fn default_enabled() -> bool {
99    true
100}
101
102/// 调度器配置文件(`scheduler.toml`)
103#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
104pub struct SchedulerConfig {
105    /// 任务列表
106    #[serde(default)]
107    pub tasks: Vec<TaskConfig>,
108}
109
110impl SchedulerConfig {
111    /// 从 TOML 字符串解析
112    ///
113    /// # 错误
114    ///
115    /// - TOML 解析失败
116    /// - 任务 ID 重复
117    pub fn from_toml_str(s: &str) -> Result<Self, CliError> {
118        let config: SchedulerConfig = toml::from_str(s)
119            .map_err(|e| CliError::Scheduler(format!("config parse error: {e}")))?;
120        config.validate()?;
121        Ok(config)
122    }
123
124    /// 从文件加载
125    ///
126    /// # 错误
127    ///
128    /// - 文件读取失败
129    /// - TOML 解析失败
130    /// - 任务 ID 重复
131    pub fn from_file(path: &std::path::Path) -> Result<Self, CliError> {
132        let content = std::fs::read_to_string(path)?;
133        Self::from_toml_str(&content)
134    }
135
136    /// 校验配置:检测重复 ID
137    fn validate(&self) -> Result<(), CliError> {
138        let mut seen = std::collections::HashSet::new();
139        for task in &self.tasks {
140            if !seen.insert(&task.id) {
141                return Err(CliError::Scheduler(format!(
142                    "duplicate task id: {}",
143                    task.id
144                )));
145            }
146        }
147        Ok(())
148    }
149
150    /// 将配置中的任务注册到调度器,并注册默认的打印 handler
151    ///
152    /// 返回注册的任务数量。
153    pub fn register_to(
154        &self,
155        scheduler: &sz_rust_core::orm::scheduler::CronScheduler,
156    ) -> Result<usize, CliError> {
157        let handler: Arc<dyn sz_rust_core::orm::scheduler::JobHandler> = Arc::new(PrintJobHandler);
158        let mut count = 0;
159        for task in &self.tasks {
160            let scheduled =
161                sz_rust_core::orm::scheduler::ScheduledTask::new(&task.id, &task.name, &task.cron)
162                    .with_callback(&task.callback);
163            let scheduled = if task.enabled {
164                scheduled
165            } else {
166                scheduled.disable()
167            };
168            scheduler.schedule(scheduled).map_err(|e| {
169                CliError::Scheduler(format!("schedule task {} failed: {e}", task.id))
170            })?;
171            scheduler.register_handler(&task.id, handler.clone());
172            count += 1;
173        }
174        Ok(count)
175    }
176}
177
178/// 执行 scheduler 子命令
179pub fn execute(cmd: &SchedulerCommand) -> Result<(), CliError> {
180    match cmd {
181        SchedulerCommand::List { config } => execute_list(config),
182        SchedulerCommand::Run { config } => execute_run(config),
183        SchedulerCommand::Start { tick_ms, config } => execute_start(*tick_ms, config),
184    }
185}
186
187/// 加载配置文件;若文件不存在则返回空配置(而非报错),便于在无配置时使用 demo 任务
188fn load_config_or_empty(path: &std::path::Path) -> Result<SchedulerConfig, CliError> {
189    if !path.exists() {
190        return Ok(SchedulerConfig::default());
191    }
192    SchedulerConfig::from_file(path)
193}
194
195/// 构建调度器并加载配置
196fn build_scheduler(
197    config: &SchedulerConfig,
198) -> Result<sz_rust_core::orm::scheduler::CronScheduler, CliError> {
199    let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
200    config.register_to(&scheduler)?;
201    Ok(scheduler)
202}
203
204/// 执行 scheduler:list
205fn execute_list(config_path: &std::path::Path) -> Result<(), CliError> {
206    let config = load_config_or_empty(config_path)?;
207
208    if config.tasks.is_empty() {
209        println!("No scheduled tasks registered.");
210        println!();
211        println!("To register tasks, create a scheduler configuration file at:");
212        println!("  {}", config_path.display());
213        println!();
214        println!("Example scheduler.toml:");
215        println!();
216        println!("  [[tasks]]");
217        println!("  id = \"cleanup-logs\"");
218        println!("  name = \"清理日志\"");
219        println!("  cron = \"0 3 * * *\"");
220        println!("  callback = \"demo::cleanup_logs\"");
221        println!("  enabled = true");
222        return Ok(());
223    }
224
225    let scheduler = build_scheduler(&config)?;
226    let tasks = scheduler.list_tasks();
227
228    println!(
229        "{:<20} {:<25} {:<25} {:<8}",
230        "ID", "Name", "Cron", "Enabled"
231    );
232    println!("{}", "-".repeat(80));
233
234    for task in &tasks {
235        println!(
236            "{:<20} {:<25} {:<25} {:<8}",
237            task.id, task.name, task.cron_expr, task.enabled
238        );
239    }
240
241    println!();
242    println!("Total: {} task(s)", tasks.len());
243    println!("Config: {}", config_path.display());
244    Ok(())
245}
246
247/// 执行 scheduler:run
248///
249/// 触发一次到期任务的执行(对齐 PHP `scheduler:run`)
250fn execute_run(config_path: &std::path::Path) -> Result<(), CliError> {
251    let config = load_config_or_empty(config_path)?;
252
253    if config.tasks.is_empty() {
254        println!("No scheduled tasks registered. Nothing to run.");
255        println!("Config: {}", config_path.display());
256        return Ok(());
257    }
258
259    let scheduler = build_scheduler(&config)?;
260    let now = chrono::Utc::now();
261
262    // 输出每个任务的下次运行时间,便于调试
263    println!("Scheduler run at {} (UTC)", now);
264    println!();
265    for task in &config.tasks {
266        match scheduler.next_run_time(&task.cron, now) {
267            Ok(next) => {
268                println!(
269                    "  {:<20} {:<25} cron={:<20} next={}",
270                    task.id, task.name, task.cron, next
271                );
272            }
273            Err(e) => {
274                println!(
275                    "  {:<20} {:<25} cron={:<20} next=N/A ({})",
276                    task.id, task.name, task.cron, e
277                );
278            }
279        }
280    }
281    println!();
282
283    let fired = scheduler.try_fire_due(now);
284    println!("Fired {} task(s) matching current time.", fired);
285    println!("Config: {}", config_path.display());
286    Ok(())
287}
288
289/// 执行 scheduler:start
290///
291/// 启动调度器并持续运行(对齐 PHP `scheduler:start`)
292fn execute_start(tick_ms: u64, config_path: &std::path::Path) -> Result<(), CliError> {
293    let config = load_config_or_empty(config_path)?;
294
295    if config.tasks.is_empty() {
296        return Err(CliError::Scheduler(format!(
297            "no tasks to schedule. Please create config file at {}",
298            config_path.display()
299        )));
300    }
301
302    let scheduler = build_scheduler(&config)?;
303    let task_count = config.tasks.len();
304
305    println!("Starting scheduler with tick interval: {}ms", tick_ms);
306    println!(
307        "Loaded {} task(s) from {}",
308        task_count,
309        config_path.display()
310    );
311    println!("Press Ctrl+C to stop.");
312    println!();
313
314    // 启动调度器
315    scheduler
316        .start(tick_ms)
317        .map_err(|e| CliError::Scheduler(e.to_string()))?;
318
319    println!("Scheduler started. Waiting for tasks to fire...");
320
321    // 阻塞主线程,直到收到 Ctrl+C 信号
322    // 注意:这是简化实现,生产环境应使用 tokio::signal::ctrl_c()
323    loop {
324        std::thread::sleep(std::time::Duration::from_secs(1));
325    }
326}
327
328/// 默认任务处理器:打印任务触发信息
329///
330/// 实际应用中应通过 `CronScheduler::register_handler` 注册自定义 handler。
331struct PrintJobHandler;
332
333impl sz_rust_core::orm::scheduler::JobHandler for PrintJobHandler {
334    fn handle(&self, task: &sz_rust_core::orm::scheduler::ScheduledTask) -> Result<(), String> {
335        println!(
336            "[{}] Task fired: {} ({}) callback={}",
337            chrono::Utc::now(),
338            task.name,
339            task.id,
340            if task.callback.is_empty() {
341                "(none)"
342            } else {
343                &task.callback
344            }
345        );
346        Ok(())
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use sz_rust_core::orm::scheduler::JobHandler;
354    use tempfile::NamedTempFile;
355
356    const SAMPLE_TOML: &str = r#"
357[[tasks]]
358id = "cleanup-logs"
359name = "清理日志"
360cron = "0 3 * * *"
361callback = "demo::cleanup_logs"
362enabled = true
363
364[[tasks]]
365id = "sync-data"
366name = "数据同步"
367cron = "*/30 * * * *"
368callback = "demo::sync_data"
369
370[[tasks]]
371id = "disabled-task"
372name = "已禁用任务"
373cron = "0 0 * * *"
374callback = "demo::disabled"
375enabled = false
376"#;
377
378    #[test]
379    fn test_config_from_toml_str() {
380        let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
381        assert_eq!(config.tasks.len(), 3);
382
383        assert_eq!(config.tasks[0].id, "cleanup-logs");
384        assert_eq!(config.tasks[0].name, "清理日志");
385        assert_eq!(config.tasks[0].cron, "0 3 * * *");
386        assert_eq!(config.tasks[0].callback, "demo::cleanup_logs");
387        assert!(config.tasks[0].enabled);
388
389        // 未指定 enabled 时默认为 true
390        assert!(config.tasks[1].enabled);
391
392        // 显式禁用
393        assert!(!config.tasks[2].enabled);
394    }
395
396    #[test]
397    fn test_config_from_toml_str_empty() {
398        let config = SchedulerConfig::from_toml_str("").unwrap();
399        assert!(config.tasks.is_empty());
400    }
401
402    #[test]
403    fn test_config_duplicate_id_rejected() {
404        let toml_str = r#"
405[[tasks]]
406id = "dup"
407name = "任务1"
408cron = "0 * * * *"
409
410[[tasks]]
411id = "dup"
412name = "任务2"
413cron = "0 * * * *"
414"#;
415        let result = SchedulerConfig::from_toml_str(toml_str);
416        assert!(result.is_err());
417        let err = result.unwrap_err().to_string();
418        assert!(err.contains("duplicate task id"), "got: {err}");
419    }
420
421    #[test]
422    fn test_config_invalid_toml_rejected() {
423        let result = SchedulerConfig::from_toml_str("not valid toml [[[[");
424        assert!(result.is_err());
425    }
426
427    #[test]
428    fn test_config_from_file() {
429        let tmp = NamedTempFile::new().unwrap();
430        std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
431
432        let config = SchedulerConfig::from_file(tmp.path()).unwrap();
433        assert_eq!(config.tasks.len(), 3);
434    }
435
436    #[test]
437    fn test_config_from_file_not_found() {
438        let result = SchedulerConfig::from_file(std::path::Path::new("/nonexistent/path.toml"));
439        assert!(result.is_err());
440    }
441
442    #[test]
443    fn test_register_to_scheduler() {
444        let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
445        let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
446
447        let count = config.register_to(&scheduler).unwrap();
448        assert_eq!(count, 3);
449
450        let tasks = scheduler.list_tasks();
451        assert_eq!(tasks.len(), 3);
452
453        // 验证 disabled 任务确实被禁用
454        let disabled = tasks.iter().find(|t| t.id == "disabled-task").unwrap();
455        assert!(!disabled.enabled);
456    }
457
458    #[test]
459    fn test_register_to_invalid_cron_rejected() {
460        let toml_str = r#"
461[[tasks]]
462id = "bad"
463name = "坏任务"
464cron = "not a cron"
465"#;
466        let config = SchedulerConfig::from_toml_str(toml_str).unwrap();
467        let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
468
469        let result = config.register_to(&scheduler);
470        assert!(result.is_err());
471        let err = result.unwrap_err().to_string();
472        assert!(err.contains("schedule task"), "got: {err}");
473    }
474
475    #[test]
476    fn test_load_config_or_empty_missing_file() {
477        let config =
478            load_config_or_empty(std::path::Path::new("/nonexistent/scheduler.toml")).unwrap();
479        assert!(config.tasks.is_empty());
480    }
481
482    #[test]
483    fn test_load_config_or_empty_existing_file() {
484        let tmp = NamedTempFile::new().unwrap();
485        std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
486
487        let config = load_config_or_empty(tmp.path()).unwrap();
488        assert_eq!(config.tasks.len(), 3);
489    }
490
491    #[test]
492    fn test_build_scheduler() {
493        let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
494        let scheduler = build_scheduler(&config).unwrap();
495        let tasks = scheduler.list_tasks();
496        assert_eq!(tasks.len(), 3);
497    }
498
499    #[test]
500    fn test_execute_list_empty_config() {
501        // 使用不存在的路径,应返回空配置并打印帮助信息
502        let result = execute_list(std::path::Path::new("/nonexistent/scheduler.toml"));
503        assert!(result.is_ok());
504    }
505
506    #[test]
507    fn test_execute_list_with_config() {
508        let tmp = NamedTempFile::new().unwrap();
509        std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
510
511        let result = execute_list(tmp.path());
512        assert!(result.is_ok());
513    }
514
515    #[test]
516    fn test_execute_run_empty_config() {
517        let result = execute_run(std::path::Path::new("/nonexistent/scheduler.toml"));
518        assert!(result.is_ok());
519    }
520
521    #[test]
522    fn test_execute_run_with_config() {
523        let tmp = NamedTempFile::new().unwrap();
524        std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
525
526        let result = execute_run(tmp.path());
527        assert!(result.is_ok());
528    }
529
530    #[test]
531    fn test_execute_start_empty_config_errors() {
532        let result = execute_start(1000, std::path::Path::new("/nonexistent/scheduler.toml"));
533        assert!(result.is_err());
534        let err = result.unwrap_err().to_string();
535        assert!(err.contains("no tasks to schedule"), "got: {err}");
536    }
537
538    #[test]
539    fn test_print_job_handler() {
540        let handler = PrintJobHandler;
541        let task = sz_rust_core::orm::scheduler::ScheduledTask::new("test", "测试", "0 * * * *")
542            .with_callback("demo::test");
543        let result = handler.handle(&task);
544        assert!(result.is_ok());
545    }
546
547    #[test]
548    fn test_print_job_handler_empty_callback() {
549        let handler = PrintJobHandler;
550        let task = sz_rust_core::orm::scheduler::ScheduledTask::new("test", "测试", "0 * * * *");
551        let result = handler.handle(&task);
552        assert!(result.is_ok());
553    }
554
555    #[test]
556    fn test_task_config_default_enabled() {
557        let toml_str = r#"
558[[tasks]]
559id = "t1"
560name = "任务"
561cron = "0 * * * *"
562"#;
563        let config = SchedulerConfig::from_toml_str(toml_str).unwrap();
564        assert!(config.tasks[0].enabled, "enabled should default to true");
565    }
566
567    #[test]
568    fn test_config_serialize_roundtrip() {
569        let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
570        let toml_str = toml::to_string(&config).unwrap();
571        let config2 = SchedulerConfig::from_toml_str(&toml_str).unwrap();
572        assert_eq!(config, config2);
573    }
574
575    #[test]
576    fn test_execute_dispatch_list() {
577        let cmd = SchedulerCommand::List {
578            config: PathBuf::from("/nonexistent/scheduler.toml"),
579        };
580        let result = execute(&cmd);
581        assert!(result.is_ok());
582    }
583
584    #[test]
585    fn test_execute_dispatch_run() {
586        let cmd = SchedulerCommand::Run {
587            config: PathBuf::from("/nonexistent/scheduler.toml"),
588        };
589        let result = execute(&cmd);
590        assert!(result.is_ok());
591    }
592
593    #[test]
594    fn test_execute_dispatch_start_empty_config() {
595        let cmd = SchedulerCommand::Start {
596            tick_ms: 1000,
597            config: PathBuf::from("/nonexistent/scheduler.toml"),
598        };
599        let result = execute(&cmd);
600        assert!(result.is_err());
601        assert!(result
602            .unwrap_err()
603            .to_string()
604            .contains("no tasks to schedule"));
605    }
606
607    #[test]
608    fn test_execute_run_with_invalid_cron() {
609        // 包含无效 cron 的配置应触发 next_run_time 错误分支
610        let tmp = NamedTempFile::new().unwrap();
611        let toml_str = r#"
612[[tasks]]
613id = "bad-cron"
614name = "坏 cron"
615cron = "not a valid cron"
616callback = "demo::bad"
617"#;
618        std::fs::write(tmp.path(), toml_str).unwrap();
619
620        let result = execute_run(tmp.path());
621        // 无效 cron 在 schedule 时就会报错,build_scheduler 返回 Err
622        assert!(result.is_err());
623    }
624}