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#[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
49pub type CronResult<T> = Result<T, CronError>;
51
52#[derive(Clone, Debug)]
57struct ScheduledJob {
58 next_run: DateTime<Utc>,
60 priority: i32,
62 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 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
97pub 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 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#[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 pub fn new() -> Self {
165 Self::default()
166 }
167
168 pub fn with_global_concurrency_limit(mut self, limit: usize) -> Self {
170 self.global_concurrency_limit = Some(limit);
171 self
172 }
173
174 pub fn with_listener(mut self, listener: Arc<dyn JobEventListener>) -> Self {
176 self.listeners.push(listener);
177 self
178 }
179
180 pub fn with_metrics_exporter(mut self, exporter: Arc<dyn MetricsExporter>) -> Self {
182 self.metrics_exporter = Some(exporter);
183 self
184 }
185
186 pub fn with_job_store(mut self, store: Arc<dyn JobStore>) -> Self {
188 self.job_store = Some(store);
189 self
190 }
191
192 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 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 pub fn builder() -> CronBuilder {
224 CronBuilder::new()
225 }
226
227 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 pub fn add_listener(&mut self, listener: Arc<dyn JobEventListener>) {
235 self.listeners.push(listener);
236 }
237
238 pub fn set_metrics_exporter(&mut self, exporter: Arc<dyn MetricsExporter>) {
240 self.metrics_exporter = Some(exporter);
241 }
242
243 pub fn set_job_store(&mut self, store: Arc<dyn JobStore>) {
245 self.job_store = Some(store);
246 }
247
248 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 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 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 pub fn remove_job(&mut self, id: &str) -> Option<JobItem> {
317 self.removed_jobs.insert(id.to_string());
319
320 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 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(()); 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, },
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, },
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 pub fn list_job_ids(&self) -> Vec<String> {
378 self.registry.keys().cloned().collect()
379 }
380
381 pub fn queue_len(&self) -> usize {
383 self.queue.len()
384 }
385
386 pub fn peek_job_id(&self) -> Option<String> {
388 self.queue.peek().map(|j| j.id.clone())
389 }
390
391 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 pub async fn run(&mut self) {
405 loop {
406 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 self.cleanup_removed_job_semaphores();
415
416 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 !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 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 due_jobs.push(self.queue.pop().unwrap());
453 } else {
454 break;
455 }
456 }
457
458 for scheduled in due_jobs {
460 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, },
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, },
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 });
499
500 if is_once_job {
503 self.removed_jobs.insert(id_cloned);
504 }
505
506 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 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 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 fn cleanup_removed_job_semaphores(&mut self) {
562 let mut to_cleanup = Vec::new();
564 for job_id in &self.removed_jobs {
565 if !self.registry.contains_key(job_id) {
567 if self.per_job_semaphores.contains_key(job_id) {
569 to_cleanup.push(job_id.clone());
570 }
571 }
572 }
573
574 for job_id in to_cleanup {
576 self.per_job_semaphores.remove(&job_id);
577 self.removed_jobs.remove(&job_id);
578 }
579 }
580}