1use crate::config::CronConfig;
7use crate::git_layer::GitLayer;
8use crate::state_store::StateStore;
9use crate::types::Priority;
10use anyhow::{Result, bail};
11use chrono::{DateTime, Utc};
12use cron::Schedule;
13use parking_lot::{Mutex, RwLock};
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16use std::str::FromStr;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use uuid::Uuid;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum JobSource {
27 Config,
29 #[default]
31 Api,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CronJob {
37 pub id: Uuid,
39 pub name: String,
41 pub schedule: String,
43 pub goal: String,
45 #[serde(default)]
47 pub constraints: Vec<String>,
48 #[serde(default)]
50 pub acceptance_criteria: Vec<String>,
51 #[serde(default = "default_toolchain")]
53 pub toolchain: String,
54 #[serde(default)]
56 pub priority: Priority,
57 #[serde(default = "default_true")]
59 pub enabled: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub last_run: Option<DateTime<Utc>>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub next_run: Option<DateTime<Utc>>,
66 #[serde(default)]
68 pub run_count: u64,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub last_result: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub last_success: Option<bool>,
75 #[serde(default)]
77 pub source: JobSource,
78}
79
80fn default_toolchain() -> String {
81 "default".into()
82}
83
84fn default_true() -> bool {
85 true
86}
87
88impl CronJob {
89 pub fn new(name: String, schedule: String, goal: String) -> Self {
91 Self {
92 id: Uuid::new_v4(),
93 name,
94 schedule,
95 goal,
96 constraints: vec![],
97 acceptance_criteria: vec![],
98 toolchain: default_toolchain(),
99 priority: Priority::default(),
100 enabled: true,
101 last_run: None,
102 next_run: None,
103 run_count: 0,
104 last_result: None,
105 last_success: None,
106 source: JobSource::Api,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct CronJobResult {
114 pub job_id: Uuid,
116 pub job_name: String,
118 pub started_at: DateTime<Utc>,
120 pub finished_at: DateTime<Utc>,
122 pub success: bool,
124 pub summary: String,
126}
127
128#[derive(Debug, Default, Deserialize)]
130pub struct CronJobUpdate {
131 pub name: Option<String>,
133 pub schedule: Option<String>,
135 pub goal: Option<String>,
137 pub constraints: Option<Vec<String>>,
139 pub acceptance_criteria: Option<Vec<String>>,
141 pub toolchain: Option<String>,
143 pub priority: Option<Priority>,
145 pub enabled: Option<bool>,
147}
148
149pub struct CronScheduler {
156 jobs: Arc<RwLock<HashMap<Uuid, CronJob>>>,
157 schedules: Arc<Mutex<HashMap<Uuid, Schedule>>>,
158 running_jobs: Arc<Mutex<HashSet<Uuid>>>,
159 state_store: Arc<StateStore>,
160 cancel: Arc<AtomicBool>,
161 dirty: Arc<AtomicBool>,
162 tick_interval_secs: u64,
163 git_layer: Option<Arc<GitLayer>>,
165 max_concurrent_jobs: usize,
167 job_timeout_secs: u64,
169}
170
171impl CronScheduler {
172 pub fn new(state_store: Arc<StateStore>, tick_interval_secs: u64) -> Self {
178 Self {
179 jobs: Arc::new(RwLock::new(HashMap::new())),
180 schedules: Arc::new(Mutex::new(HashMap::new())),
181 running_jobs: Arc::new(Mutex::new(HashSet::new())),
182 state_store,
183 cancel: Arc::new(AtomicBool::new(false)),
184 dirty: Arc::new(AtomicBool::new(false)),
185 tick_interval_secs,
186 git_layer: None,
187 max_concurrent_jobs: 3,
188 job_timeout_secs: 600,
189 }
190 }
191
192 pub fn set_max_concurrent_jobs(&mut self, max: usize) {
199 if max == 0 {
200 tracing::warn!("set_max_concurrent_jobs(0) would disable all cron jobs; clamping to 1");
201 self.max_concurrent_jobs = 1;
202 } else {
203 self.max_concurrent_jobs = max;
204 }
205 }
206
207 pub fn set_job_timeout_secs(&mut self, secs: u64) {
209 self.job_timeout_secs = secs;
210 }
211
212 pub fn set_git_layer(&mut self, gl: Arc<GitLayer>) {
214 self.git_layer = Some(gl);
215 }
216
217 fn normalize_expr(expr: &str) -> String {
219 let fields: Vec<&str> = expr.split_whitespace().collect();
220 match fields.len() {
221 5 => format!("0 {expr}"),
222 _ => expr.to_string(),
223 }
224 }
225
226 fn parse_schedule(&self, expr: &str) -> Result<Schedule> {
228 let normalized = Self::normalize_expr(expr);
229 Schedule::from_str(&normalized)
230 .map_err(|e| anyhow::anyhow!("Invalid cron expression '{expr}': {e}"))
231 }
232
233 fn next_fire_time(&self, schedule: &Schedule, after: &DateTime<Utc>) -> Option<DateTime<Utc>> {
235 schedule.after(after).next()
236 }
237
238 pub async fn add_job(&self, job: CronJob) -> Result<Uuid> {
240 let schedule = self.parse_schedule(&job.schedule)?;
241 let next = self.next_fire_time(&schedule, &Utc::now());
242 let id = job.id;
243
244 self.schedules.lock().insert(id, schedule);
245 self.jobs.write().insert(
246 id,
247 CronJob {
248 next_run: next,
249 ..job
250 },
251 );
252 self.dirty.store(true, Ordering::Relaxed);
253 self.persist_jobs().await;
254
255 tracing::info!(
256 name = %self.jobs.read().get(&id).map(|j| j.name.as_str()).unwrap_or("?"),
257 %id,
258 "Cron job added"
259 );
260 Ok(id)
261 }
262
263 pub async fn remove_job(&self, id: Uuid) -> Result<()> {
265 self.schedules.lock().remove(&id);
266 self.jobs
267 .write()
268 .remove(&id)
269 .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
270 self.dirty.store(true, Ordering::Relaxed);
271 self.persist_jobs().await;
272 tracing::info!(%id, "Cron job removed");
273 Ok(())
274 }
275
276 pub async fn update_job(&self, id: Uuid, update: CronJobUpdate) -> Result<()> {
278 let should_persist = {
281 let mut jobs = self.jobs.write();
282 let job = jobs
283 .get_mut(&id)
284 .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
285
286 if let Some(name) = update.name {
287 job.name = name;
288 }
289 if let Some(schedule) = &update.schedule {
290 let parsed = self.parse_schedule(schedule)?;
291 self.schedules.lock().insert(id, parsed);
292 job.schedule = schedule.clone();
293 let sched = self.schedules.lock().get(&id).cloned();
295 if let Some(s) = sched {
296 job.next_run = self.next_fire_time(&s, &Utc::now());
297 }
298 }
299 if let Some(goal) = update.goal {
300 job.goal = goal;
301 }
302 if let Some(constraints) = update.constraints {
303 job.constraints = constraints;
304 }
305 if let Some(criteria) = update.acceptance_criteria {
306 job.acceptance_criteria = criteria;
307 }
308 if let Some(toolchain) = update.toolchain {
309 job.toolchain = toolchain;
310 }
311 if let Some(priority) = update.priority {
312 job.priority = priority;
313 }
314 if let Some(enabled) = update.enabled {
315 job.enabled = enabled;
316 }
317
318 self.dirty.store(true, Ordering::Relaxed);
319 true
320 }; if should_persist {
323 self.persist_jobs().await;
324 }
325 Ok(())
326 }
327
328 pub async fn toggle_job(&self, id: Uuid, enabled: bool) -> Result<()> {
330 self.update_job(
331 id,
332 CronJobUpdate {
333 enabled: Some(enabled),
334 ..Default::default()
335 },
336 )
337 .await
338 }
339
340 pub fn list_jobs(&self) -> Vec<CronJob> {
342 self.jobs.read().values().cloned().collect()
343 }
344
345 pub fn get_job(&self, id: Uuid) -> Option<CronJob> {
347 self.jobs.read().get(&id).cloned()
348 }
349
350 pub fn is_running(&self, id: Uuid) -> bool {
352 self.running_jobs.lock().contains(&id)
353 }
354
355 pub fn trigger_job(&self, id: Uuid) -> Result<CronJob> {
359 let job = self
360 .jobs
361 .read()
362 .get(&id)
363 .cloned()
364 .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
365
366 if self.running_jobs.lock().contains(&id) {
367 bail!("Job '{}' is already running", job.name);
368 }
369
370 self.running_jobs.lock().insert(id);
371 Ok(job)
372 }
373
374 pub async fn mark_job_completed(&self, id: Uuid, success: bool, summary: String) {
376 self.running_jobs.lock().remove(&id);
377 let new_next_run = {
378 let mut jobs = self.jobs.write();
379 if let Some(job) = jobs.get_mut(&id) {
380 job.last_run = Some(Utc::now());
381 job.last_result = Some(summary);
382 job.last_success = Some(success);
383 job.run_count += 1;
384 let sched = self.schedules.lock().get(&id).cloned();
386 sched.and_then(|s| self.next_fire_time(&s, &Utc::now()))
387 } else {
388 None
389 }
390 };
391 if let Some(next_run) = new_next_run {
392 let mut jobs = self.jobs.write();
393 if let Some(job) = jobs.get_mut(&id) {
394 job.next_run = Some(next_run);
395 }
396 }
397 self.dirty.store(true, Ordering::Relaxed);
398 self.persist_jobs().await;
399 }
400
401 pub fn stop(&self) {
403 self.cancel.store(true, Ordering::Relaxed);
404 tracing::info!("Cron scheduler stop requested");
405 }
406
407 pub async fn start<F, Fut>(self: Arc<Self>, executor: F)
430 where
431 F: Fn(Uuid, String) -> Fut + Send + Sync + 'static,
432 Fut: std::future::Future<Output = (bool, String)> + Send + 'static,
433 {
434 let executor = Arc::new(executor);
435 let mut interval =
436 tokio::time::interval(std::time::Duration::from_secs(self.tick_interval_secs));
437
438 tracing::info!(
439 interval_secs = self.tick_interval_secs,
440 "Cron scheduler started"
441 );
442
443 loop {
444 tokio::select! {
445 _ = interval.tick() => {
446 if self.cancel.load(Ordering::Relaxed) {
447 tracing::info!("Cron scheduler stopped");
448 return;
449 }
450 self.tick_once(&executor).await;
454 }
455 }
456 }
457 }
458
459 pub async fn tick_once<F, Fut>(&self, executor: &Arc<F>)
466 where
467 F: Fn(Uuid, String) -> Fut + Send + Sync + 'static,
468 Fut: std::future::Future<Output = (bool, String)> + Send + 'static,
469 {
470 let now = Utc::now();
471
472 let current_running = self.running_jobs.lock().len();
474 if current_running >= self.max_concurrent_jobs {
475 tracing::debug!(
476 running = current_running,
477 max = self.max_concurrent_jobs,
478 "Cron tick: max concurrent jobs reached, skipping"
479 );
480 return;
481 }
482
483 let due: Vec<(Uuid, String)> = {
484 let jobs = self.jobs.read();
485 jobs.iter()
486 .filter(|(_, job)| {
487 job.enabled
488 && job.next_run.is_some_and(|nr| nr <= now)
489 && !self.running_jobs.lock().contains(&job.id)
490 })
491 .map(|(_, job)| (job.id, job.goal.clone()))
492 .collect()
493 };
494
495 let total_due = due.len();
496 for (spawned, (id, goal)) in due.into_iter().enumerate() {
497 if self.running_jobs.lock().len() >= self.max_concurrent_jobs {
499 tracing::info!(
500 spawned,
501 remaining = total_due - spawned,
502 "Cron tick: max concurrent jobs reached, deferring remaining"
503 );
504 break;
505 }
506
507 self.running_jobs.lock().insert(id);
508 let exec = executor.clone();
509 let me = self.clone();
510 let timeout_secs = self.job_timeout_secs;
511 tokio::spawn(async move {
512 tracing::info!(%id, "Cron job triggered");
513 let result = tokio::time::timeout(
514 std::time::Duration::from_secs(timeout_secs),
515 exec(id, goal),
516 )
517 .await;
518
519 let (success, summary) = match result {
520 Ok((s, m)) => (s, m),
521 Err(_) => {
522 tracing::error!(%id, timeout_secs, "Cron job timed out");
523 (false, format!("Timed out after {timeout_secs} seconds"))
524 }
525 };
526 tracing::info!(%id, success, "Cron job completed");
527 me.mark_job_completed(id, success, summary).await;
528 });
529 }
530 }
531
532 pub(crate) async fn persist_jobs(&self) {
535 let job_list: Vec<CronJob> = {
536 let jobs = self.jobs.read();
537 jobs.values().cloned().collect()
538 };
539 if let Err(e) = self.state_store.save_json("cron", "jobs", &job_list).await {
540 tracing::error!("Failed to persist cron jobs: {}", e);
541 }
542 if let Some(ref gl) = self.git_layer
544 && gl.is_enabled()
545 {
546 let _ = gl.commit_file("cron/jobs.json", "cron: update jobs");
547 }
548 }
549
550 pub async fn restore_jobs(&self) {
552 match self
553 .state_store
554 .load_json::<Vec<CronJob>>("cron", "jobs")
555 .await
556 {
557 Ok(Some(job_list)) => {
558 for mut job in job_list {
559 match self.parse_schedule(&job.schedule) {
561 Ok(schedule) => {
562 job.next_run = self.next_fire_time(&schedule, &Utc::now());
563 self.schedules.lock().insert(job.id, schedule);
564 self.jobs.write().insert(job.id, job);
565 }
566 Err(e) => {
567 tracing::error!(job = %job.name, error = %e, "Skipping job with invalid schedule");
568 }
569 }
570 }
571 tracing::info!(count = self.jobs.read().len(), "Cron jobs restored");
572 }
573 Ok(None) => {
574 tracing::info!("No saved cron jobs found");
575 }
576 Err(e) => {
577 tracing::error!("Failed to restore cron jobs: {}", e);
578 }
579 }
580 }
581
582 pub async fn load_from_config(&self, config: &CronConfig) {
585 if !config.enabled {
586 tracing::info!("Cron scheduler is disabled in config");
587 return;
588 }
589
590 for (name, inline) in &config.jobs {
591 let schedule = inline.schedule.clone();
592 let goal = inline.goal.clone();
593
594 let job = CronJob {
595 id: Uuid::new_v4(),
596 name: name.clone(),
597 schedule: schedule.clone(),
598 goal,
599 constraints: inline.constraints.clone(),
600 acceptance_criteria: inline.acceptance_criteria.clone(),
601 toolchain: inline.toolchain.clone(),
602 priority: inline.priority,
603 enabled: inline.enabled,
604 last_run: None,
605 next_run: None,
606 run_count: 0,
607 last_result: None,
608 last_success: None,
609 source: JobSource::Config,
610 };
611
612 {
614 let jobs = self.jobs.read();
615 if jobs.values().any(|j| j.name == *name) {
616 tracing::debug!(name = %name, "Skipping config job — already exists via API");
617 continue;
618 }
619 }
620
621 if let Err(e) = self.add_job(job).await {
622 tracing::error!(name = %name, error = %e, "Failed to load config job");
623 } else {
624 tracing::info!(name = %name, "Loaded cron job from config");
625 }
626 }
627 }
628}
629
630impl Clone for CronScheduler {
631 fn clone(&self) -> Self {
632 Self {
633 jobs: self.jobs.clone(),
634 schedules: self.schedules.clone(),
635 running_jobs: self.running_jobs.clone(),
636 state_store: self.state_store.clone(),
637 cancel: self.cancel.clone(),
638 dirty: self.dirty.clone(),
639 tick_interval_secs: self.tick_interval_secs,
640 git_layer: self.git_layer.clone(),
641 max_concurrent_jobs: self.max_concurrent_jobs,
642 job_timeout_secs: self.job_timeout_secs,
643 }
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use chrono::Timelike;
651
652 fn test_store() -> Arc<StateStore> {
653 let temp_dir = tempfile::tempdir().unwrap();
654 Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap())
655 }
656
657 #[test]
658 fn test_normalize_5field() {
659 assert_eq!(CronScheduler::normalize_expr("0 9 * * *"), "0 0 9 * * *");
660 }
661
662 #[test]
663 fn test_normalize_6field() {
664 assert_eq!(CronScheduler::normalize_expr("0 0 9 * * *"), "0 0 9 * * *");
665 }
666
667 #[test]
668 fn test_normalize_7field() {
669 assert_eq!(
670 CronScheduler::normalize_expr("0 0 9 * * * 2026"),
671 "0 0 9 * * * 2026"
672 );
673 }
674
675 #[test]
676 fn test_parse_valid() {
677 let cs = CronScheduler::new(test_store(), 60);
678 assert!(cs.parse_schedule("0 9 * * *").is_ok());
679 }
680
681 #[test]
682 fn test_parse_invalid() {
683 let cs = CronScheduler::new(test_store(), 60);
684 assert!(cs.parse_schedule("invalid").is_err());
685 }
686
687 #[test]
688 fn test_next_fire_time_daily() {
689 let cs = CronScheduler::new(test_store(), 60);
690 let schedule = cs.parse_schedule("0 9 * * *").unwrap();
691 let now = chrono::NaiveDate::from_ymd_opt(2026, 5, 6)
692 .unwrap()
693 .and_hms_opt(8, 0, 0)
694 .unwrap();
695 let now_utc = DateTime::<Utc>::from_naive_utc_and_offset(now, Utc);
696 let next = cs.next_fire_time(&schedule, &now_utc);
697 assert!(next.is_some());
698 let next = next.unwrap();
699 assert_eq!(next.hour(), 9);
700 }
701
702 #[test]
703 fn test_next_fire_time_every_15min() {
704 let cs = CronScheduler::new(test_store(), 60);
705 let schedule = cs.parse_schedule("*/15 * * * *").unwrap();
706 let now = chrono::NaiveDate::from_ymd_opt(2026, 5, 6)
707 .unwrap()
708 .and_hms_opt(10, 7, 0)
709 .unwrap();
710 let now_utc = DateTime::<Utc>::from_naive_utc_and_offset(now, Utc);
711 let next = cs.next_fire_time(&schedule, &now_utc);
712 assert!(next.is_some());
713 let next = next.unwrap();
714 assert_eq!(next.minute(), 15);
715 }
716
717 #[test]
718 fn test_add_job_computes_next_run() {
719 let job = CronJob::new("test".into(), "0 9 * * *".into(), "Test goal".into());
720 assert!(job.next_run.is_none()); assert!(job.enabled);
722 assert_eq!(job.run_count, 0);
723 }
724
725 #[test]
726 fn test_job_source_default() {
727 let job = CronJob::new("test".into(), "0 9 * * *".into(), "goal".into());
728 assert_eq!(job.source, JobSource::Api);
729 }
730
731 #[tokio::test]
732 async fn test_add_job() {
733 let store = test_store();
734 let cs = CronScheduler::new(store, 60);
735 let job = CronJob::new("test-job".into(), "0 9 * * *".into(), "Run me".into());
736 let id = cs.add_job(job).await.unwrap();
737 assert!(cs.get_job(id).is_some());
738 assert_eq!(cs.list_jobs().len(), 1);
739 }
740
741 #[tokio::test]
742 async fn test_remove_job() {
743 let store = test_store();
744 let cs = CronScheduler::new(store, 60);
745 let job = CronJob::new("remove-me".into(), "0 10 * * *".into(), "Gone".into());
746 let id = cs.add_job(job).await.unwrap();
747 cs.remove_job(id).await.unwrap();
748 assert!(cs.get_job(id).is_none());
749 }
750
751 #[tokio::test]
752 async fn test_trigger_job() {
753 let store = test_store();
754 let cs = CronScheduler::new(store, 60);
755 let job = CronJob::new("trigger-me".into(), "0 11 * * *".into(), "Goal text".into());
756 let id = cs.add_job(job).await.unwrap();
757
758 let triggered = cs.trigger_job(id).unwrap();
759 assert_eq!(triggered.goal, "Goal text");
760 assert!(cs.is_running(id));
761
762 cs.mark_job_completed(id, true, "ok".into()).await;
763 assert!(!cs.is_running(id));
764 }
765
766 #[tokio::test]
767 async fn test_trigger_already_running() {
768 let store = test_store();
769 let cs = CronScheduler::new(store, 60);
770 let job = CronJob::new("running".into(), "0 12 * * *".into(), "goal".into());
771 let id = cs.add_job(job).await.unwrap();
772 cs.trigger_job(id).unwrap();
773 let result = cs.trigger_job(id);
774 assert!(result.is_err());
775 }
776
777 #[tokio::test]
778 async fn test_update_job() {
779 let store = test_store();
780 let cs = CronScheduler::new(store, 60);
781 let job = CronJob::new("old-name".into(), "0 9 * * *".into(), "old goal".into());
782 let id = cs.add_job(job).await.unwrap();
783
784 cs.update_job(
785 id,
786 CronJobUpdate {
787 name: Some("new-name".into()),
788 goal: Some("new goal".into()),
789 enabled: Some(false),
790 ..Default::default()
791 },
792 )
793 .await
794 .unwrap();
795
796 let updated = cs.get_job(id).unwrap();
797 assert_eq!(updated.name, "new-name");
798 assert_eq!(updated.goal, "new goal");
799 assert!(!updated.enabled);
800 }
801
802 #[tokio::test]
803 async fn test_toggle_job() {
804 let store = test_store();
805 let cs = CronScheduler::new(store, 60);
806 let job = CronJob::new("toggle".into(), "0 9 * * *".into(), "goal".into());
807 let id = cs.add_job(job).await.unwrap();
808 assert!(cs.get_job(id).unwrap().enabled);
809
810 cs.toggle_job(id, false).await.unwrap();
811 assert!(!cs.get_job(id).unwrap().enabled);
812
813 cs.toggle_job(id, true).await.unwrap();
814 assert!(cs.get_job(id).unwrap().enabled);
815 }
816
817 #[tokio::test]
818 async fn test_mark_completed_updates_next_run() {
819 let store = test_store();
820 let cs = CronScheduler::new(store, 60);
821 let job = CronJob::new("comp".into(), "*/5 * * * *".into(), "goal".into());
822 let id = cs.add_job(job).await.unwrap();
823
824 let before = cs.get_job(id).unwrap().next_run;
825 assert!(before.is_some());
826
827 let now = Utc::now();
829 {
830 let mut jobs = cs.jobs.write();
831 if let Some(j) = jobs.get_mut(&id) {
832 j.next_run = Some(now - chrono::Duration::minutes(5));
833 }
834 }
835
836 cs.mark_job_completed(id, true, "ok".into()).await;
837 let after = cs.get_job(id).unwrap().next_run;
838 assert!(after.is_some());
839 assert!(after.unwrap() >= now);
841 }
842
843 #[test]
844 fn test_max_concurrent_enforced() {
845 let temp_dir = tempfile::tempdir().unwrap();
846 let store = Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap());
847 let mut scheduler = CronScheduler::new(store, 60);
848 scheduler.set_max_concurrent_jobs(2);
849 assert_eq!(scheduler.max_concurrent_jobs, 2);
850 }
851
852 #[test]
853 fn test_job_timeout_configurable() {
854 let temp_dir = tempfile::tempdir().unwrap();
855 let store = Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap());
856 let mut scheduler = CronScheduler::new(store, 60);
857 scheduler.set_job_timeout_secs(300);
858 assert_eq!(scheduler.job_timeout_secs, 300);
859 }
860
861 async fn add_due_job(cs: &CronScheduler, name: &str, goal: &str) -> Uuid {
865 let id = cs
866 .add_job(CronJob::new(name.into(), "*/5 * * * *".into(), goal.into()))
867 .await
868 .unwrap();
869 {
872 let mut jobs = cs.jobs.write();
873 if let Some(j) = jobs.get_mut(&id) {
874 j.next_run = Some(Utc::now() - chrono::Duration::seconds(60));
875 }
876 }
877 id
878 }
879
880 #[tokio::test]
881 async fn tick_once_runs_due_jobs_and_marks_completion() {
882 let cs = Arc::new(CronScheduler::new(test_store(), 60));
883 let id = add_due_job(&cs, "due", "do the thing").await;
884
885 let called = Arc::new(Mutex::new(Vec::<(Uuid, String)>::new()));
886 let called_for_tick = called.clone();
887 let exec = Arc::new(move |jid: Uuid, goal: String| {
888 let called = called_for_tick.clone();
889 async move {
890 called.lock().push((jid, goal));
891 (true, "ok".into())
892 }
893 });
894
895 cs.tick_once(&exec).await;
896
897 for _ in 0..50 {
899 if cs.get_job(id).unwrap().run_count >= 1 {
900 break;
901 }
902 tokio::task::yield_now().await;
903 }
904
905 let log = called.lock().clone();
906 assert_eq!(log, vec![(id, "do the thing".into())]);
907 let job = cs.get_job(id).unwrap();
908 assert_eq!(job.run_count, 1, "completion must increment run_count");
909 assert_eq!(job.last_success, Some(true));
910 assert_eq!(job.last_result.as_deref(), Some("ok"));
911 assert!(job.last_run.is_some(), "completion must set last_run");
912 assert!(job.next_run.unwrap() > Utc::now() - chrono::Duration::seconds(1));
914 assert!(!cs.is_running(id), "completion must clear running flag");
915 }
916
917 #[tokio::test]
918 async fn tick_once_skips_disabled_jobs() {
919 let cs = Arc::new(CronScheduler::new(test_store(), 60));
920 let id = add_due_job(&cs, "off", "ignored").await;
921 cs.toggle_job(id, false).await.unwrap();
922
923 let called = Arc::new(Mutex::new(0u32));
924 let called_for_tick = called.clone();
925 let exec = Arc::new(move |_jid: Uuid, _goal: String| {
926 let called = called_for_tick.clone();
927 async move {
928 *called.lock() += 1;
929 (true, String::new())
930 }
931 });
932
933 cs.tick_once(&exec).await;
934 tokio::task::yield_now().await;
935
936 assert_eq!(*called.lock(), 0);
937 assert_eq!(cs.get_job(id).unwrap().run_count, 0);
938 }
939
940 #[tokio::test]
941 async fn tick_once_prevents_overlap_with_already_running_job() {
942 let cs = Arc::new(CronScheduler::new(test_store(), 60));
947 let id = add_due_job(&cs, "long", "slow").await;
948 cs.running_jobs.lock().insert(id);
951
952 let called = Arc::new(Mutex::new(0u32));
953 let called_for_tick = called.clone();
954 let exec = Arc::new(move |_jid: Uuid, _goal: String| {
955 let called = called_for_tick.clone();
956 async move {
957 *called.lock() += 1;
958 (true, String::new())
959 }
960 });
961
962 cs.tick_once(&exec).await;
963 tokio::task::yield_now().await;
964
965 assert_eq!(
966 *called.lock(),
967 0,
968 "overlap-prevention must skip a job already in running_jobs"
969 );
970 cs.running_jobs.lock().remove(&id);
972 }
973
974 #[tokio::test]
975 async fn tick_once_respects_max_concurrent_jobs() {
976 let mut cs = CronScheduler::new(test_store(), 60);
977 cs.set_max_concurrent_jobs(2);
978 let cs = Arc::new(cs);
979
980 add_due_job(&cs, "a", "a").await;
982 add_due_job(&cs, "b", "b").await;
983 add_due_job(&cs, "c", "c").await;
984
985 let called = Arc::new(Mutex::new(0u32));
986 let called_for_tick = called.clone();
987 let exec = Arc::new(move |_jid: Uuid, _goal: String| {
988 let called = called_for_tick.clone();
989 async move {
990 *called.lock() += 1;
991 (true, String::new())
992 }
993 });
994
995 cs.tick_once(&exec).await;
996 for _ in 0..200 {
999 if *called.lock() >= 2 {
1000 break;
1001 }
1002 tokio::task::yield_now().await;
1003 }
1004 assert_eq!(
1005 *called.lock(),
1006 2,
1007 "tick_once must enforce max_concurrent_jobs"
1008 );
1009 }
1010}