Skip to main content

cja/cron/
worker.rs

1use std::{collections::HashMap, time::Duration};
2
3use chrono::{DateTime, Utc};
4use chrono_tz::Tz;
5use tokio_util::sync::CancellationToken;
6
7use crate::app_state::AppState as AS;
8
9use super::registry::{CronRegistry, TickError};
10
11/// Worker that executes cron jobs on a schedule
12///
13/// The worker runs cron jobs based on their configured schedules. For cron expressions,
14/// the timezone parameter determines when the cron expression is evaluated. For example,
15/// a cron expression "0 9 * * *" (9 AM daily) will run at 9 AM in the configured timezone.
16///
17/// Interval-based jobs ignore the timezone and run based on elapsed time since last run.
18pub struct Worker<AppState: AS> {
19    id: uuid::Uuid,
20    state: AppState,
21    registry: CronRegistry<AppState>,
22    pub(crate) started_at: DateTime<Utc>,
23    timezone: Tz,
24    sleep_duration: Duration,
25}
26
27impl<AppState: AS> Worker<AppState> {
28    /// Create a new Worker with UTC as the default timezone
29    pub fn new(state: AppState, registry: CronRegistry<AppState>) -> Self {
30        Self::new_with_timezone(state, registry, chrono_tz::UTC, Duration::from_mins(1))
31    }
32
33    /// Create a new Worker with a specific timezone
34    pub fn new_with_timezone(
35        state: AppState,
36        registry: CronRegistry<AppState>,
37        timezone: Tz,
38        sleep_duration: Duration,
39    ) -> Self {
40        Self {
41            id: uuid::Uuid::new_v4(),
42            state,
43            registry,
44            started_at: Utc::now(),
45            timezone,
46            sleep_duration,
47        }
48    }
49
50    /// Run the cron worker until the shutdown token is cancelled.
51    ///
52    /// The worker will execute cron jobs on their configured schedules until shutdown
53    /// is requested. When the `shutdown_token` is cancelled:
54    /// - The worker stops scheduling new cron job executions
55    /// - Any currently running tick completes before shutdown
56    /// - The sleep between ticks can be interrupted for faster shutdown
57    ///
58    /// # Arguments
59    ///
60    /// * `shutdown_token` - Cancellation token for graceful shutdown
61    ///
62    /// # Example
63    ///
64    /// ```ignore
65    /// use tokio_util::sync::CancellationToken;
66    ///
67    /// let shutdown_token = CancellationToken::new();
68    /// let worker_token = shutdown_token.clone();
69    ///
70    /// tokio::spawn(async move {
71    ///     worker.run(worker_token).await.unwrap();
72    /// });
73    ///
74    /// // Later, trigger shutdown
75    /// shutdown_token.cancel();
76    /// ```
77    pub async fn run(self, shutdown_token: CancellationToken) -> Result<(), TickError> {
78        tracing::debug!(cron_worker_id = %self.id, "Starting Cron loop");
79        while !shutdown_token.is_cancelled() {
80            // Dropping tick on cancellation can interrupt a direct callback
81            // after its side effect but before last_run_at is persisted.
82            // Finish the current tick, including its error or completion write.
83            self.tick().await?;
84
85            tokio::select! {
86                () = tokio::time::sleep(self.sleep_duration) => {}
87                () = shutdown_token.cancelled() => {
88                    tracing::info!(cron_worker_id = %self.id, "Cron worker shutdown during sleep");
89                    break;
90                }
91            }
92        }
93
94        tracing::info!(cron_worker_id = %self.id, "Cron worker shutdown complete");
95        Ok(())
96    }
97
98    async fn last_enqueue_map(&self) -> Result<HashMap<String, DateTime<Utc>>, TickError> {
99        let last_runs = sqlx::query!("SELECT name, last_run_at FROM Crons")
100            .fetch_all(self.state.db())
101            .await
102            .map_err(TickError::SqlxError)?;
103
104        let last_run_map: HashMap<String, DateTime<Utc>> = last_runs
105            .iter()
106            .map(|row| (row.name.clone(), row.last_run_at))
107            .collect();
108
109        Ok(last_run_map)
110    }
111
112    #[tracing::instrument(name = "cron.tick", level = "trace", skip_all, fields(cron_worker.id = %self.id))]
113    pub(crate) async fn tick(&self) -> Result<(), TickError> {
114        let last_enqueue_map = self.last_enqueue_map().await?;
115        for job in self.registry.jobs.values() {
116            job.tick(
117                self.state.clone(),
118                &last_enqueue_map,
119                self.started_at,
120                self.timezone,
121            )
122            .await?;
123        }
124
125        Ok(())
126    }
127}