Skip to main content

oximedia_workflow/
scheduler.rs

1//! Workflow scheduling and triggers.
2
3use crate::error::{Result, WorkflowError};
4use crate::workflow::{Workflow, WorkflowId};
5use chrono::{DateTime, Utc};
6use cron::Schedule;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::str::FromStr;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15/// Source of the current wall-clock time used by the scheduler.
16///
17/// Production code uses [`SystemClock`] (the default), which reads
18/// [`chrono::Utc::now`]. Tests can inject a deterministic clock to drive cron
19/// evaluation at logical times without sleeping, by passing a custom
20/// implementation to [`WorkflowScheduler::with_clock`].
21pub trait Clock: Send + Sync {
22    /// Return the current time as a UTC timestamp.
23    fn now(&self) -> chrono::DateTime<chrono::Utc>;
24}
25
26/// Default [`Clock`] implementation backed by the system wall clock.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct SystemClock;
29
30impl Clock for SystemClock {
31    fn now(&self) -> chrono::DateTime<chrono::Utc> {
32        chrono::Utc::now()
33    }
34}
35
36/// Trigger type for workflow execution.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum Trigger {
40    /// Cron-style schedule.
41    Cron {
42        /// Cron expression.
43        expression: String,
44        /// Timezone (defaults to UTC).
45        #[serde(default)]
46        timezone: String,
47    },
48
49    /// Watch folder for file changes.
50    WatchFolder {
51        /// Folder path to watch.
52        path: PathBuf,
53        /// File pattern to match (glob).
54        #[serde(default)]
55        pattern: String,
56        /// Whether to watch recursively.
57        #[serde(default)]
58        recursive: bool,
59    },
60
61    /// Manual/API trigger.
62    Manual,
63
64    /// Time-based trigger (one-time).
65    Time {
66        /// Scheduled time.
67        at: DateTime<Utc>,
68    },
69
70    /// Event-based trigger.
71    Event {
72        /// Event name/type.
73        event_type: String,
74        /// Event filter conditions.
75        #[serde(default)]
76        conditions: HashMap<String, String>,
77    },
78
79    /// Interval trigger.
80    Interval {
81        /// Interval duration in seconds.
82        seconds: u64,
83    },
84}
85
86/// Scheduled workflow.
87#[derive(Debug, Clone)]
88pub struct ScheduledWorkflow {
89    /// Workflow to execute.
90    pub workflow: Workflow,
91    /// Trigger configuration.
92    pub trigger: Trigger,
93    /// Whether the schedule is enabled.
94    pub enabled: bool,
95    /// Last execution time.
96    pub last_execution: Option<DateTime<Utc>>,
97    /// Next scheduled execution time.
98    pub next_execution: Option<DateTime<Utc>>,
99}
100
101impl ScheduledWorkflow {
102    /// Create a new scheduled workflow.
103    #[must_use]
104    pub fn new(workflow: Workflow, trigger: Trigger) -> Self {
105        let next_execution = Self::calculate_next_execution(&trigger, None);
106        Self {
107            workflow,
108            trigger,
109            enabled: true,
110            last_execution: None,
111            next_execution,
112        }
113    }
114
115    /// Calculate next execution time.
116    fn calculate_next_execution(
117        trigger: &Trigger,
118        after: Option<DateTime<Utc>>,
119    ) -> Option<DateTime<Utc>> {
120        let now = after.unwrap_or_else(Utc::now);
121
122        match trigger {
123            Trigger::Cron { expression, .. } => {
124                if let Ok(schedule) = Schedule::from_str(expression) {
125                    schedule.after(&now).next()
126                } else {
127                    None
128                }
129            }
130            Trigger::Time { at } => {
131                if *at > now {
132                    Some(*at)
133                } else {
134                    None
135                }
136            }
137            Trigger::Interval { seconds } => {
138                Some(now + chrono::Duration::seconds(i64::try_from(*seconds).unwrap_or(60)))
139            }
140            Trigger::Manual | Trigger::WatchFolder { .. } | Trigger::Event { .. } => None,
141        }
142    }
143
144    /// Update next execution time after execution.
145    pub fn update_next_execution(&mut self) {
146        self.update_next_execution_at(Utc::now());
147    }
148
149    /// Update next execution time after an execution that occurred at `now`.
150    ///
151    /// This is the clock-injectable variant of [`Self::update_next_execution`];
152    /// the latter is a thin wrapper that supplies [`Utc::now`].
153    pub(crate) fn update_next_execution_at(&mut self, now: DateTime<Utc>) {
154        self.last_execution = Some(now);
155        self.next_execution = Self::calculate_next_execution(&self.trigger, self.last_execution);
156    }
157
158    /// Check if workflow should execute now.
159    #[must_use]
160    pub fn should_execute(&self) -> bool {
161        self.should_execute_at(Utc::now())
162    }
163
164    /// Check if the workflow should execute at the supplied `now`.
165    ///
166    /// This is the clock-injectable variant of [`Self::should_execute`]; the
167    /// latter is a thin wrapper that supplies [`Utc::now`].
168    #[must_use]
169    pub(crate) fn should_execute_at(&self, now: DateTime<Utc>) -> bool {
170        if !self.enabled {
171            return false;
172        }
173
174        match self.next_execution {
175            Some(next) => now >= next,
176            None => false,
177        }
178    }
179}
180
181/// Workflow scheduler.
182pub struct WorkflowScheduler {
183    /// Scheduled workflows.
184    schedules: Arc<RwLock<HashMap<WorkflowId, ScheduledWorkflow>>>,
185    /// Whether scheduler is running.
186    running: Arc<RwLock<bool>>,
187    /// Clock used to read the current time when evaluating schedules.
188    clock: Arc<dyn Clock>,
189}
190
191impl WorkflowScheduler {
192    /// Create a new scheduler backed by the system wall clock.
193    #[must_use]
194    pub fn new() -> Self {
195        Self::with_clock(Arc::new(SystemClock))
196    }
197
198    /// Create a new scheduler that reads time from the supplied [`Clock`].
199    ///
200    /// Production callers should use [`Self::new`] (which uses [`SystemClock`]);
201    /// this constructor exists so tests can inject a deterministic clock and
202    /// drive cron evaluation at logical times without sleeping.
203    #[must_use]
204    pub fn with_clock(clock: Arc<dyn Clock>) -> Self {
205        Self {
206            schedules: Arc::new(RwLock::new(HashMap::new())),
207            running: Arc::new(RwLock::new(false)),
208            clock,
209        }
210    }
211
212    /// Add a scheduled workflow.
213    pub async fn add_schedule(&self, workflow: Workflow, trigger: Trigger) -> Result<WorkflowId> {
214        let workflow_id = workflow.id;
215        let scheduled = ScheduledWorkflow::new(workflow, trigger);
216
217        let mut schedules = self.schedules.write().await;
218        schedules.insert(workflow_id, scheduled);
219
220        info!("Added scheduled workflow: {}", workflow_id);
221        Ok(workflow_id)
222    }
223
224    /// Remove a scheduled workflow.
225    pub async fn remove_schedule(&self, workflow_id: WorkflowId) -> Result<()> {
226        let mut schedules = self.schedules.write().await;
227        schedules.remove(&workflow_id);
228        info!("Removed scheduled workflow: {}", workflow_id);
229        Ok(())
230    }
231
232    /// Enable/disable a schedule.
233    pub async fn set_schedule_enabled(&self, workflow_id: WorkflowId, enabled: bool) -> Result<()> {
234        let mut schedules = self.schedules.write().await;
235        if let Some(schedule) = schedules.get_mut(&workflow_id) {
236            schedule.enabled = enabled;
237            debug!("Schedule {} enabled: {}", workflow_id, enabled);
238            Ok(())
239        } else {
240            Err(WorkflowError::WorkflowNotFound(workflow_id.to_string()))
241        }
242    }
243
244    /// Get all scheduled workflows.
245    pub async fn list_schedules(&self) -> Vec<(WorkflowId, ScheduledWorkflow)> {
246        let schedules = self.schedules.read().await;
247        schedules
248            .iter()
249            .map(|(id, sched)| (*id, sched.clone()))
250            .collect()
251    }
252
253    /// Start the scheduler.
254    pub async fn start(&self) -> Result<()> {
255        let mut running = self.running.write().await;
256        if *running {
257            return Err(WorkflowError::AlreadyRunning("Scheduler".to_string()));
258        }
259        *running = true;
260        drop(running);
261
262        info!("Scheduler started");
263        Ok(())
264    }
265
266    /// Stop the scheduler.
267    pub async fn stop(&self) -> Result<()> {
268        let mut running = self.running.write().await;
269        if !*running {
270            return Err(WorkflowError::NotRunning("Scheduler".to_string()));
271        }
272        *running = false;
273
274        info!("Scheduler stopped");
275        Ok(())
276    }
277
278    /// Check for workflows ready to execute.
279    pub async fn check_schedules(&self) -> Vec<Workflow> {
280        let running = self.running.read().await;
281        if !*running {
282            return Vec::new();
283        }
284        drop(running);
285
286        // Read the clock exactly once so every schedule is evaluated against a
287        // single consistent timestamp within this check pass.
288        let now = self.clock.now();
289
290        let mut schedules = self.schedules.write().await;
291        let mut ready_workflows = Vec::new();
292
293        for (_, schedule) in schedules.iter_mut() {
294            if schedule.should_execute_at(now) {
295                ready_workflows.push(schedule.workflow.clone());
296                schedule.update_next_execution_at(now);
297                debug!(
298                    "Workflow {} ready for execution. Next: {:?}",
299                    schedule.workflow.id, schedule.next_execution
300                );
301            }
302        }
303
304        ready_workflows
305    }
306
307    /// Get next execution time for a workflow.
308    pub async fn get_next_execution(&self, workflow_id: WorkflowId) -> Option<DateTime<Utc>> {
309        let schedules = self.schedules.read().await;
310        schedules.get(&workflow_id).and_then(|s| s.next_execution)
311    }
312}
313
314impl Default for WorkflowScheduler {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320/// File watcher for watch folder triggers.
321pub struct FileWatcher {
322    /// Watched paths and their workflow IDs.
323    watches: Arc<RwLock<HashMap<PathBuf, Vec<WorkflowId>>>>,
324}
325
326impl FileWatcher {
327    /// Create a new file watcher.
328    #[must_use]
329    pub fn new() -> Self {
330        Self {
331            watches: Arc::new(RwLock::new(HashMap::new())),
332        }
333    }
334
335    /// Add a watch for a path.
336    pub async fn add_watch(&self, path: PathBuf, workflow_id: WorkflowId) -> Result<()> {
337        let mut watches = self.watches.write().await;
338        watches.entry(path.clone()).or_default().push(workflow_id);
339        info!("Added file watch: {:?} -> {}", path, workflow_id);
340        Ok(())
341    }
342
343    /// Remove a watch.
344    pub async fn remove_watch(&self, path: &PathBuf, workflow_id: WorkflowId) -> Result<()> {
345        let mut watches = self.watches.write().await;
346        if let Some(workflows) = watches.get_mut(path) {
347            workflows.retain(|id| *id != workflow_id);
348            if workflows.is_empty() {
349                watches.remove(path);
350            }
351        }
352        info!("Removed file watch: {:?} -> {}", path, workflow_id);
353        Ok(())
354    }
355
356    /// Get workflows for a path.
357    pub async fn get_workflows_for_path(&self, path: &PathBuf) -> Vec<WorkflowId> {
358        let watches = self.watches.read().await;
359        watches.get(path).cloned().unwrap_or_default()
360    }
361}
362
363impl Default for FileWatcher {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_cron_trigger_parsing() {
375        let trigger = Trigger::Cron {
376            expression: "0 0 * * * *".to_string(), // Every hour
377            timezone: "UTC".to_string(),
378        };
379
380        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
381        assert!(next.is_some());
382    }
383
384    #[test]
385    fn test_interval_trigger() {
386        let trigger = Trigger::Interval { seconds: 60 };
387        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
388        assert!(next.is_some());
389    }
390
391    #[test]
392    fn test_time_trigger_future() {
393        let future_time = Utc::now() + chrono::Duration::hours(1);
394        let trigger = Trigger::Time { at: future_time };
395        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
396        assert_eq!(next, Some(future_time));
397    }
398
399    #[test]
400    fn test_time_trigger_past() {
401        let past_time = Utc::now() - chrono::Duration::hours(1);
402        let trigger = Trigger::Time { at: past_time };
403        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
404        assert!(next.is_none());
405    }
406
407    #[test]
408    fn test_scheduled_workflow_creation() {
409        let workflow = Workflow::new("test");
410        let trigger = Trigger::Manual;
411        let scheduled = ScheduledWorkflow::new(workflow, trigger);
412
413        assert!(scheduled.enabled);
414        assert!(scheduled.last_execution.is_none());
415    }
416
417    #[tokio::test]
418    async fn test_scheduler_creation() {
419        let scheduler = WorkflowScheduler::new();
420        assert!(!*scheduler.running.read().await);
421    }
422
423    #[tokio::test]
424    async fn test_add_schedule() {
425        let scheduler = WorkflowScheduler::new();
426        let workflow = Workflow::new("test-workflow");
427        let trigger = Trigger::Manual;
428
429        let workflow_id = scheduler
430            .add_schedule(workflow, trigger)
431            .await
432            .expect("should succeed in test");
433        let schedules = scheduler.list_schedules().await;
434
435        assert_eq!(schedules.len(), 1);
436        assert_eq!(schedules[0].0, workflow_id);
437    }
438
439    #[tokio::test]
440    async fn test_remove_schedule() {
441        let scheduler = WorkflowScheduler::new();
442        let workflow = Workflow::new("test-workflow");
443        let trigger = Trigger::Manual;
444
445        let workflow_id = scheduler
446            .add_schedule(workflow, trigger)
447            .await
448            .expect("should succeed in test");
449        scheduler
450            .remove_schedule(workflow_id)
451            .await
452            .expect("should succeed in test");
453
454        let schedules = scheduler.list_schedules().await;
455        assert_eq!(schedules.len(), 0);
456    }
457
458    #[tokio::test]
459    async fn test_enable_disable_schedule() {
460        let scheduler = WorkflowScheduler::new();
461        let workflow = Workflow::new("test-workflow");
462        let trigger = Trigger::Manual;
463
464        let workflow_id = scheduler
465            .add_schedule(workflow, trigger)
466            .await
467            .expect("should succeed in test");
468
469        scheduler
470            .set_schedule_enabled(workflow_id, false)
471            .await
472            .expect("should succeed in test");
473        let schedules = scheduler.list_schedules().await;
474        assert!(!schedules[0].1.enabled);
475
476        scheduler
477            .set_schedule_enabled(workflow_id, true)
478            .await
479            .expect("should succeed in test");
480        let schedules = scheduler.list_schedules().await;
481        assert!(schedules[0].1.enabled);
482    }
483
484    #[tokio::test]
485    async fn test_scheduler_start_stop() {
486        let scheduler = WorkflowScheduler::new();
487
488        scheduler.start().await.expect("should succeed in test");
489        assert!(*scheduler.running.read().await);
490
491        scheduler.stop().await.expect("should succeed in test");
492        assert!(!*scheduler.running.read().await);
493    }
494
495    #[tokio::test]
496    async fn test_file_watcher() {
497        let watcher = FileWatcher::new();
498        let path = std::env::temp_dir().join("oximedia-workflow-scheduler-test");
499        let workflow_id = WorkflowId::new();
500
501        watcher
502            .add_watch(path.clone(), workflow_id)
503            .await
504            .expect("should succeed in test");
505        let workflows = watcher.get_workflows_for_path(&path).await;
506        assert_eq!(workflows.len(), 1);
507        assert_eq!(workflows[0], workflow_id);
508
509        watcher
510            .remove_watch(&path, workflow_id)
511            .await
512            .expect("should succeed in test");
513        let workflows = watcher.get_workflows_for_path(&path).await;
514        assert_eq!(workflows.len(), 0);
515    }
516}