Skip to main content

foxtive_cron/
lib.rs

1use crate::contracts::{
2    JobContract, JobEvent, JobEventListener, JobStore, JobType, MetricsExporter, MisfirePolicy,
3};
4pub use crate::job::JobItem;
5use chrono::{DateTime, Utc};
6use std::cmp::Ordering;
7use std::collections::{BinaryHeap, HashMap, HashSet};
8use std::sync::Arc;
9use thiserror::Error;
10use tokio::sync::Semaphore;
11use tokio::task::JoinSet;
12use tokio::time::{Instant, sleep_until};
13use tokio_util::sync::CancellationToken;
14use tracing::{error, info, warn};
15
16pub mod builder;
17pub mod contracts;
18mod fn_job;
19mod job;
20
21pub use builder::CronExpression;
22pub use fn_job::FnJob;
23
24/// Custom error types for the `foxtive-cron` library.
25#[derive(Debug, Error)]
26pub enum CronError {
27    #[error("Invalid cron expression: {0}")]
28    InvalidSchedule(String),
29
30    #[error("Job not found: {0}")]
31    JobNotFound(String),
32
33    #[error("Job execution failed: {0}")]
34    ExecutionError(#[from] anyhow::Error),
35
36    #[error("Task join error: {0}")]
37    JoinError(#[from] tokio::task::JoinError),
38
39    #[error("Internal error: {0}")]
40    Internal(String),
41
42    #[error("Scheduler is shutting down")]
43    ShuttingDown,
44
45    #[error("Persistence error: {0}")]
46    PersistenceError(String),
47}
48
49/// A type alias for results returned by cron jobs, using [`CronError`].
50pub type CronResult<T> = Result<T, CronError>;
51
52/// Represents a job scheduled to run at a specific time.
53///
54/// Used internally in a min-heap (`BinaryHeap`) to efficiently track
55/// the next job due for execution.
56#[derive(Clone, Debug)]
57struct ScheduledJob {
58    /// The next time this job is scheduled to run.
59    next_run: DateTime<Utc>,
60    /// The job's priority.
61    priority: i32,
62    /// The job ID to identify which job in the registry to execute.
63    id: String,
64}
65
66impl Eq for ScheduledJob {}
67
68impl PartialEq for ScheduledJob {
69    fn eq(&self, other: &Self) -> bool {
70        self.next_run == other.next_run && self.priority == other.priority
71    }
72}
73
74impl Ord for ScheduledJob {
75    /// Reverse ordering so the job with the **earliest `next_run`** is at the top.
76    /// If `next_run` is the same, use `priority` as a tie-breaker (higher priority first).
77    fn cmp(&self, other: &Self) -> Ordering {
78        match other.next_run.cmp(&self.next_run) {
79            Ordering::Equal => self.priority.cmp(&other.priority),
80            ord => ord,
81        }
82    }
83}
84
85impl PartialOrd for ScheduledJob {
86    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
87        Some(self.cmp(other))
88    }
89}
90
91impl Default for Cron {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97/// An asynchronous job scheduler that runs registered jobs based on cron expressions.
98///
99/// `Cron` supports:
100/// - Adding fully custom jobs via the [`JobContract`] trait.
101/// - Registering async closures using [`add_job_fn`](Self::add_job_fn).
102/// - Registering blocking closures using [`add_blocking_job_fn`](Self::add_blocking_job_fn).
103///
104/// Jobs are executed concurrently in separate Tokio tasks and automatically
105/// rescheduled after each execution according to their cron schedule.
106pub struct Cron {
107    queue: BinaryHeap<ScheduledJob>,
108    registry: HashMap<String, JobItem>,
109    global_concurrency_limit: Option<Arc<Semaphore>>,
110    per_job_semaphores: HashMap<String, Arc<Semaphore>>,
111    listeners: Vec<Arc<dyn JobEventListener>>,
112    metrics_exporter: Option<Arc<dyn MetricsExporter>>,
113    job_store: Option<Arc<dyn JobStore>>,
114    shutdown_token: CancellationToken,
115    tasks: JoinSet<()>,
116    /// Track which jobs have been removed but may still be running
117    removed_jobs: HashSet<String>,
118}
119
120impl std::fmt::Debug for Cron {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("Cron")
123            .field("queue_len", &self.queue.len())
124            .field("registry_len", &self.registry.len())
125            .field(
126                "global_concurrency_limit",
127                &self.global_concurrency_limit.is_some(),
128            )
129            .field("per_job_semaphores_len", &self.per_job_semaphores.len())
130            .field("listeners_len", &self.listeners.len())
131            .field("metrics_exporter", &self.metrics_exporter.is_some())
132            .field("job_store", &self.job_store.is_some())
133            .field(
134                "shutdown_token_cancelled",
135                &self.shutdown_token.is_cancelled(),
136            )
137            .field("removed_jobs_count", &self.removed_jobs.len())
138            .finish()
139    }
140}
141
142/// A builder for the `Cron` scheduler.
143#[derive(Default)]
144pub struct CronBuilder {
145    global_concurrency_limit: Option<usize>,
146    listeners: Vec<Arc<dyn JobEventListener>>,
147    metrics_exporter: Option<Arc<dyn MetricsExporter>>,
148    job_store: Option<Arc<dyn JobStore>>,
149}
150
151impl std::fmt::Debug for CronBuilder {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("CronBuilder")
154            .field("global_concurrency_limit", &self.global_concurrency_limit)
155            .field("listeners_len", &self.listeners.len())
156            .field("metrics_exporter", &self.metrics_exporter.is_some())
157            .field("job_store", &self.job_store.is_some())
158            .finish()
159    }
160}
161
162impl CronBuilder {
163    /// Creates a new `CronBuilder`.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Sets a global concurrency limit for the scheduler.
169    pub fn with_global_concurrency_limit(mut self, limit: usize) -> Self {
170        self.global_concurrency_limit = Some(limit);
171        self
172    }
173
174    /// Adds an event listener to the scheduler.
175    pub fn with_listener(mut self, listener: Arc<dyn JobEventListener>) -> Self {
176        self.listeners.push(listener);
177        self
178    }
179
180    /// Sets a metrics exporter for the scheduler.
181    pub fn with_metrics_exporter(mut self, exporter: Arc<dyn MetricsExporter>) -> Self {
182        self.metrics_exporter = Some(exporter);
183        self
184    }
185
186    /// Sets a job store for the scheduler.
187    pub fn with_job_store(mut self, store: Arc<dyn JobStore>) -> Self {
188        self.job_store = Some(store);
189        self
190    }
191
192    /// Builds the `Cron` scheduler.
193    pub fn build(self) -> Cron {
194        let mut cron = Cron::new();
195        if let Some(limit) = self.global_concurrency_limit {
196            cron = cron.with_global_concurrency_limit(limit);
197        }
198        cron.listeners = self.listeners;
199        cron.metrics_exporter = self.metrics_exporter;
200        cron.job_store = self.job_store;
201        cron
202    }
203}
204
205impl Cron {
206    /// Creates a new empty `Cron` scheduler.
207    pub fn new() -> Self {
208        Self {
209            queue: BinaryHeap::new(),
210            registry: HashMap::new(),
211            global_concurrency_limit: None,
212            per_job_semaphores: HashMap::new(),
213            listeners: Vec::new(),
214            metrics_exporter: None,
215            job_store: None,
216            shutdown_token: CancellationToken::new(),
217            tasks: JoinSet::new(),
218            removed_jobs: HashSet::new(),
219        }
220    }
221
222    /// Creates a `CronBuilder` for configuring the scheduler.
223    pub fn builder() -> CronBuilder {
224        CronBuilder::new()
225    }
226
227    /// Sets a global concurrency limit for the scheduler.
228    pub fn with_global_concurrency_limit(mut self, limit: usize) -> Self {
229        self.global_concurrency_limit = Some(Arc::new(Semaphore::new(limit)));
230        self
231    }
232
233    /// Adds an event listener to the scheduler.
234    pub fn add_listener(&mut self, listener: Arc<dyn JobEventListener>) {
235        self.listeners.push(listener);
236    }
237
238    /// Sets a metrics exporter for the scheduler.
239    pub fn set_metrics_exporter(&mut self, exporter: Arc<dyn MetricsExporter>) {
240        self.metrics_exporter = Some(exporter);
241    }
242
243    /// Sets a job store for the scheduler.
244    pub fn set_job_store(&mut self, store: Arc<dyn JobStore>) {
245        self.job_store = Some(store);
246    }
247
248    /// Adds a custom job that implements the [`JobContract`] trait.
249    ///
250    /// This is the most flexible way to schedule complex job types.
251    ///
252    /// # Errors
253    /// Returns an error if the job's schedule expression is invalid.
254    pub fn add_job(&mut self, job: impl JobContract + 'static) -> CronResult<()> {
255        let job_item = JobItem::new(
256            Arc::new(job),
257            self.listeners.clone(),
258            self.metrics_exporter.clone(),
259            self.job_store.clone(),
260        )?;
261        let id = job_item.id().to_string();
262
263        if let Some(limit) = job_item.concurrency_limit() {
264            self.per_job_semaphores
265                .insert(id.clone(), Arc::new(Semaphore::new(limit)));
266        }
267
268        if let Some(next_run) = job_item.next_run_time() {
269            self.queue.push(ScheduledJob {
270                next_run,
271                priority: job_item.priority(),
272                id: id.clone(),
273            });
274        }
275
276        self.registry.insert(id, job_item);
277        Ok(())
278    }
279
280    /// Adds a job from an asynchronous closure or `async fn`.
281    pub fn add_job_fn<F, Fut>(
282        &mut self,
283        id: impl Into<String>,
284        name: impl Into<String>,
285        schedule_expr: &str,
286        func: F,
287    ) -> CronResult<()>
288    where
289        F: Fn() -> Fut + Send + Sync + 'static,
290        Fut: std::future::Future<Output = CronResult<()>> + Send + 'static,
291    {
292        let job = FnJob::new(id, name, schedule_expr, func)?;
293        self.add_job(job)
294    }
295
296    /// Adds a job from a **blocking** closure or function.
297    pub fn add_blocking_job_fn<F>(
298        &mut self,
299        id: impl Into<String>,
300        name: impl Into<String>,
301        schedule_expr: &str,
302        func: F,
303    ) -> CronResult<()>
304    where
305        F: Fn() -> CronResult<()> + Send + Sync + 'static + Clone,
306    {
307        let job = FnJob::new_blocking(id, name, schedule_expr, func)?;
308        self.add_job(job)
309    }
310
311    /// Removes a job from the scheduler by its ID.
312    ///
313    /// Note: This does not stop already running instances of the job,
314    /// but prevents future executions from being scheduled.
315    /// The semaphore for this job will be kept until all running instances complete.
316    pub fn remove_job(&mut self, id: &str) -> Option<JobItem> {
317        // Mark as removed so we know not to reschedule it
318        self.removed_jobs.insert(id.to_string());
319
320        // Remove from registry but keep semaphore for running instances
321        self.registry.remove(id)
322    }
323
324    async fn emit_event(&self, event: JobEvent) {
325        for listener in &self.listeners {
326            listener.on_event(event.clone()).await;
327        }
328    }
329
330    /// Triggers a job to run immediately.
331    ///
332    /// This does not affect the job's regular schedule.
333    pub async fn trigger_job(&mut self, id: &str) -> CronResult<()> {
334        if self.shutdown_token.is_cancelled() {
335            return Err(CronError::ShuttingDown);
336        }
337
338        if let Some(job_item) = self.registry.get(id) {
339            let job_item_to_spawn = job_item.clone();
340            let name = job_item.name().to_string();
341
342            let global_semaphore = self.global_concurrency_limit.clone();
343            let job_semaphore = self.per_job_semaphores.get(id).cloned();
344
345            let _scheduler_weak = Arc::new(()); // Dummy for now, we need a real weak ref to Cron if we want to remove Once jobs properly
346
347            self.tasks.spawn(async move {
348                let _global_permit = match global_semaphore {
349                    Some(sem) => match sem.acquire_owned().await {
350                        Ok(permit) => Some(permit),
351                        Err(_) => return, // Semaphore closed
352                    },
353                    None => None,
354                };
355
356                let _job_permit = match job_semaphore {
357                    Some(sem) => match sem.acquire_owned().await {
358                        Ok(permit) => Some(permit),
359                        Err(_) => return, // Semaphore closed
360                    },
361                    None => None,
362                };
363
364                info!("[{name}] Manually triggering job");
365                match job_item_to_spawn.run().await {
366                    Ok(()) => info!("[{name}] Manual job completed"),
367                    Err(err) => error!("[{name}] Manual job failed: {err:?}"),
368                }
369            });
370            Ok(())
371        } else {
372            Err(CronError::JobNotFound(id.to_string()))
373        }
374    }
375
376    /// Returns a list of IDs for all registered jobs.
377    pub fn list_job_ids(&self) -> Vec<String> {
378        self.registry.keys().cloned().collect()
379    }
380
381    /// Returns the number of jobs currently in the queue.
382    pub fn queue_len(&self) -> usize {
383        self.queue.len()
384    }
385
386    /// Returns the next job in the queue without removing it.
387    pub fn peek_job_id(&self) -> Option<String> {
388        self.queue.peek().map(|j| j.id.clone())
389    }
390
391    /// Signals the scheduler to stop and waits for all active jobs to finish.
392    pub async fn shutdown(&mut self) {
393        info!("Shutting down cron scheduler...");
394        self.shutdown_token.cancel();
395        while let Some(res) = self.tasks.join_next().await {
396            if let Err(err) = res {
397                error!("Error joining task during shutdown: {:?}", err);
398            }
399        }
400        info!("Cron scheduler shutdown complete.");
401    }
402
403    /// Starts the scheduler loop.
404    pub async fn run(&mut self) {
405        loop {
406            // Cleanup finished tasks from JoinSet to prevent memory leak
407            while let Some(result) = self.tasks.try_join_next() {
408                if let Err(err) = result {
409                    error!("Error in scheduled task: {:?}", err);
410                }
411            }
412
413            // Clean up semaphores for removed jobs that are no longer running
414            self.cleanup_removed_job_semaphores();
415
416            // Peek at the next job without removing it.
417            let (next_run, id) = match self.queue.peek() {
418                Some(scheduled) => (scheduled.next_run, scheduled.id.clone()),
419                None => {
420                    warn!("Cron queue is empty, scheduler exiting");
421                    return;
422                }
423            };
424
425            // If the job was removed from registry, pop it from queue and continue.
426            if !self.registry.contains_key(&id) {
427                self.queue.pop();
428                continue;
429            }
430
431            let now = Utc::now();
432            if next_run > now {
433                let delay = (next_run - now).to_std().unwrap_or_default();
434                tokio::select! {
435                    _ = sleep_until(Instant::now() + delay) => {}
436                    _ = self.shutdown_token.cancelled() => {
437                        return;
438                    }
439                }
440            }
441
442            if self.shutdown_token.is_cancelled() {
443                return;
444            }
445
446            // Drain all jobs that are due now (handles multiple jobs at the same tick).
447            let now = Utc::now();
448            let mut due_jobs = Vec::new();
449            while let Some(scheduled) = self.queue.peek() {
450                if scheduled.next_run <= now {
451                    // Safe to unwrap: we just peeked successfully.
452                    due_jobs.push(self.queue.pop().unwrap());
453                } else {
454                    break;
455                }
456            }
457
458            // Spawn each due job concurrently and re-queue for its next run.
459            for scheduled in due_jobs {
460                // Only run if it still exists in registry
461                if let Some(job_item) = self.registry.get(&scheduled.id) {
462                    let job_item_to_spawn = job_item.clone();
463                    let name = job_item.name().to_string();
464
465                    let global_semaphore = self.global_concurrency_limit.clone();
466                    let job_semaphore = self.per_job_semaphores.get(&scheduled.id).cloned();
467
468                    let scheduled_time = scheduled.next_run;
469
470                    let name_cloned = name.clone();
471                    let id_cloned = scheduled.id.clone();
472                    let is_once_job = job_item.job_type() == JobType::Once;
473
474                    self.tasks.spawn(async move {
475                        let _global_permit = match global_semaphore {
476                            Some(sem) => match sem.acquire_owned().await {
477                                Ok(permit) => Some(permit),
478                                Err(_) => return, // Semaphore closed
479                            },
480                            None => None,
481                        };
482
483                        let _job_permit = match job_semaphore {
484                            Some(sem) => match sem.acquire_owned().await {
485                                Ok(permit) => Some(permit),
486                                Err(_) => return, // Semaphore closed
487                            },
488                            None => None,
489                        };
490
491                        info!("[{name}] Running job");
492                        match job_item_to_spawn.run().await {
493                            Ok(()) => info!("[{name}] Job completed"),
494                            Err(err) => error!("[{name}] Job failed: {err:?}"),
495                        }
496
497                        // Permits are automatically returned when dropped
498                    });
499
500                    // One-time jobs should be marked for removal after they complete.
501                    // We defer actual cleanup until after the job completes.
502                    if is_once_job {
503                        self.removed_jobs.insert(id_cloned);
504                    }
505
506                    // Re-schedule based on misfire policy
507                    let now = Utc::now();
508                    let misfire_policy = job_item.misfire_policy();
509
510                    if scheduled_time < now {
511                        self.emit_event(JobEvent::Misfired {
512                            id: scheduled.id.clone(),
513                            name: name_cloned.clone(),
514                            scheduled_time,
515                        })
516                        .await;
517
518                        if let Some(exporter) = &self.metrics_exporter {
519                            exporter.record_misfire(&scheduled.id, &name_cloned);
520                        }
521                    }
522
523                    match misfire_policy {
524                        MisfirePolicy::Skip => {
525                            if let Some(next_run) = job_item.next_run_time() {
526                                self.queue.push(ScheduledJob {
527                                    next_run,
528                                    priority: job_item.priority(),
529                                    id: scheduled.id,
530                                });
531                            }
532                        }
533                        MisfirePolicy::FireOnce => {
534                            // If we're behind schedule, fire once as soon as possible.
535                            // The next execution after 'now' will resume regular schedule.
536                            if let Some(next_run) = job_item.next_run_time() {
537                                self.queue.push(ScheduledJob {
538                                    next_run,
539                                    priority: job_item.priority(),
540                                    id: scheduled.id,
541                                });
542                            }
543                        }
544                        MisfirePolicy::FireAll => {
545                            // Find the very next occurrence after the one we just processed
546                            if let Some(next) = job_item.next_run_after(scheduled_time) {
547                                self.queue.push(ScheduledJob {
548                                    next_run: next,
549                                    priority: job_item.priority(),
550                                    id: scheduled.id,
551                                });
552                            }
553                        }
554                    }
555                }
556            }
557        }
558    }
559
560    /// Clean up semaphores for jobs that have been removed and are no longer running.
561    fn cleanup_removed_job_semaphores(&mut self) {
562        // Find jobs that are both removed AND no longer running
563        let mut to_cleanup = Vec::new();
564        for job_id in &self.removed_jobs {
565            // Check if job is still in registry (running one-time jobs)
566            if !self.registry.contains_key(job_id) {
567                // Check if semaphore exists (might have been cleaned already)
568                if self.per_job_semaphores.contains_key(job_id) {
569                    to_cleanup.push(job_id.clone());
570                }
571            }
572        }
573
574        // Clean up semaphores for fully completed removed jobs
575        for job_id in to_cleanup {
576            self.per_job_semaphores.remove(&job_id);
577            self.removed_jobs.remove(&job_id);
578        }
579    }
580}