Skip to main content

chronon_runtime/
coordinator_service.rs

1//! Job and run CRUD for HTTP handlers and host integration.
2
3use std::sync::Arc;
4
5use chrono::Utc;
6use chronon_core::models::{Job, JobRevision, Run, RunStatus, ScheduleKind};
7use chronon_core::store::SchedulerStore;
8use chronon_core::{ChrononError, Result};
9use chronon_scheduler::{partition_hash_i64_for_job_id, CronExpr, job_execution_pool_id};
10use serde_json::Value;
11
12/// Job and run CRUD backed by [`SchedulerStore`] — no background loops.
13///
14/// Use this when the host (or Axum handlers) needs to upsert jobs, pause/resume, list runs,
15/// or trigger [`Self::run_now`] without owning scheduler ticks. Obtained from
16/// [`crate::Chronon::coordinator_service`] or constructed with [`Self::new`] for HTTP /
17/// Mode 3 API hosts.
18///
19/// | Method | Role |
20/// |--------|------|
21/// | [`Self::upsert_job`] | Insert/update; computes partition hash and cron `next_run_at` |
22/// | [`Self::run_now`] | Enqueue an immediate run (required for [`ScheduleKind::Manual`](chronon_core::ScheduleKind::Manual)) |
23/// | [`Self::list_jobs`] / [`Self::list_runs`] | Admin / HTTP list |
24///
25/// For remote processes that cannot share the store, use [`crate::RemoteCoordinatorClient`]
26/// against a host that mounts `chronon_router` (Mode 3).
27///
28/// # Examples
29///
30/// ```
31/// use std::sync::Arc;
32/// use chronon_backend_mem::InMemorySchedulerStore;
33/// use chronon_core::{Job, ScheduleKind, SchedulerStore};
34/// use chronon_runtime::CoordinatorService;
35///
36/// # #[tokio::main]
37/// # async fn main() -> chronon_core::Result<()> {
38/// let store = Arc::new(InMemorySchedulerStore::new());
39/// let coordinator = CoordinatorService::new(store.clone());
40///
41/// let mut job = Job::new("manual-job", "noop");
42/// job.schedule_kind = ScheduleKind::Manual;
43/// coordinator.upsert_job(job.clone()).await?;
44///
45/// let run_id = coordinator.run_now(&job.job_id).await?;
46/// let run = store.get_run(&run_id).await?.expect("queued");
47/// assert_eq!(run.status, chronon_core::RunStatus::Queued);
48/// # Ok(())
49/// # }
50/// ```
51pub struct CoordinatorService {
52    store: Arc<dyn SchedulerStore>,
53}
54
55impl CoordinatorService {
56    /// Wraps an existing store; does not start background tasks.
57    pub fn new(store: Arc<dyn SchedulerStore>) -> Self {
58        Self { store }
59    }
60
61    /// Underlying store for advanced host queries.
62    pub fn store(&self) -> Arc<dyn SchedulerStore> {
63        Arc::clone(&self.store)
64    }
65
66    /// Insert or update a job, computing partition hash and next cron fire time when needed.
67    ///
68    /// Appends a [`JobRevision`] when `current_revision` changes.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use std::sync::Arc;
74    /// use chronon_backend_mem::InMemorySchedulerStore;
75    /// use chronon_core::{Job, ScheduleKind};
76    /// use chronon_runtime::CoordinatorService;
77    ///
78    /// # #[tokio::main]
79    /// # async fn main() -> chronon_core::Result<()> {
80    /// let store = Arc::new(InMemorySchedulerStore::new());
81    /// let coordinator = CoordinatorService::new(store);
82    /// let mut job = Job::new("nightly", "noop");
83    /// job.schedule_kind = ScheduleKind::Manual;
84    /// coordinator.upsert_job(job).await?;
85    /// assert_eq!(coordinator.list_jobs().await?.len(), 1);
86    /// # Ok(())
87    /// # }
88    /// ```
89    ///
90    /// End-to-end with a real script: `cargo run -p uf-chronon --example script_macro --features mem`
91    /// (stringly job) or `script_handle_job` (typed `ScriptHandle` defaults).
92    pub async fn upsert_job(&self, mut job: Job) -> Result<()> {
93        if job.partition_hash.is_none() {
94            job.partition_hash = Some(partition_hash_i64_for_job_id(&job.job_id));
95        }
96        if job.schedule_kind == ScheduleKind::Cron {
97            if let Some(ref cron_expr) = job.cron_expr {
98                let cron = CronExpr::parse(cron_expr, job.timezone.as_deref())?;
99                job.next_run_at = cron.next_from_now();
100            }
101        }
102        job.updated_at = Utc::now();
103
104        if let Some(existing) = self.store.get_job(&job.job_id).await? {
105            if existing.current_revision != job.current_revision {
106                let revision = JobRevision::new(
107                    &job.job_id,
108                    job.current_revision,
109                    job.actor_json.clone(),
110                    serde_json::to_value(&job)?,
111                );
112                self.store.append_revision(&revision).await?;
113            }
114        } else {
115            let revision = JobRevision::new(
116                &job.job_id,
117                1,
118                job.actor_json.clone(),
119                serde_json::to_value(&job)?,
120            );
121            self.store.append_revision(&revision).await?;
122        }
123
124        self.store.upsert_job(&job).await
125    }
126
127    /// Load a job by stable `job_id`.
128    pub async fn get_job(&self, job_id: &str) -> Option<Job> {
129        self.store.get_job(job_id).await.ok().flatten()
130    }
131
132    /// Load a job by human-readable `job_name`.
133    pub async fn get_job_by_name(&self, job_name: &str) -> Option<Job> {
134        self.store
135            .get_job_by_name(job_name)
136            .await
137            .ok()
138            .flatten()
139    }
140
141    /// All jobs in the store.
142    ///
143    /// # Errors
144    ///
145    /// Returns a storage error when the underlying store fails.
146    pub async fn list_jobs(&self) -> Result<Vec<Job>> {
147        self.store.list_jobs().await
148    }
149
150    /// Disable scheduling for `job_id` without deleting the row.
151    pub async fn pause_job(&self, job_id: &str) -> Result<()> {
152        self.store.pause_job(job_id).await
153    }
154
155    /// Re-enable scheduling for `job_id`.
156    pub async fn resume_job(&self, job_id: &str) -> Result<()> {
157        self.store.resume_job(job_id).await
158    }
159
160    /// Paginated run listing with optional `job_id` and status string filters.
161    ///
162    /// Unrecognized status strings are ignored (no filter).
163    pub async fn list_runs(
164        &self,
165        job_id: Option<&str>,
166        status: Option<&str>,
167        offset: usize,
168        limit: usize,
169    ) -> Result<Vec<Run>> {
170        let status_filter = status.and_then(parse_run_status);
171        self.store
172            .list_runs_filtered(job_id, status_filter, offset, limit)
173            .await
174    }
175
176    /// Load a single run by `run_id`.
177    pub async fn get_run(&self, run_id: &str) -> Result<Option<Run>> {
178        self.store.get_run(run_id).await
179    }
180
181    /// Revision history for `job_id`, oldest first per store ordering.
182    pub async fn list_revisions(&self, job_id: &str) -> Result<Vec<JobRevision>> {
183        self.store.list_revisions(job_id).await
184    }
185
186    /// Enqueue an immediate run using the job's stored `params_json`.
187    ///
188    /// Returns the new `run_id`. Errors with [`ChrononError::JobNotFound`] when missing.
189    ///
190    /// Manual jobs ([`ScheduleKind::Manual`]) are never due for the tick loop; use this
191    /// (or HTTP `POST /jobs/run_now`) to trigger them. Works for any schedule kind.
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use std::sync::Arc;
197    /// use chronon_backend_mem::InMemorySchedulerStore;
198    /// use chronon_core::{Job, ScheduleKind};
199    /// use chronon_runtime::CoordinatorService;
200    ///
201    /// # #[tokio::main]
202    /// # async fn main() -> chronon_core::Result<()> {
203    /// let coordinator = CoordinatorService::new(Arc::new(InMemorySchedulerStore::new()));
204    /// let mut job = Job::new("probe", "noop");
205    /// job.schedule_kind = ScheduleKind::Manual;
206    /// coordinator.upsert_job(job.clone()).await?;
207    /// let run_id = coordinator.run_now(&job.job_id).await?;
208    /// assert!(!run_id.is_empty());
209    /// # Ok(())
210    /// # }
211    /// ```
212    ///
213    /// Runnable sample: `cargo run -p uf-chronon --example run_now --features mem`.
214    pub async fn run_now(&self, job_id: &str) -> Result<String> {
215        self.run_now_with_params(job_id, None).await
216    }
217
218    /// Enqueue an immediate run, optionally overriding params JSON.
219    pub async fn run_now_with_params(
220        &self,
221        job_id: &str,
222        params_override: Option<Value>,
223    ) -> Result<String> {
224        let Some(job) = self.store.get_job(job_id).await? else {
225            return Err(ChrononError::JobNotFound(job_id.to_string()));
226        };
227        let now = Utc::now();
228        let mut run = Run::for_job(&job.job_id, &job.script_name, now);
229        run.actor_json = job.actor_json.clone();
230        run.params_json = params_override.unwrap_or_else(|| job.params_json.clone());
231        run.pool_id = Some(job_execution_pool_id(&job));
232        let run_id = run.run_id.clone();
233        self.store.create_run(&run).await?;
234        Ok(run_id)
235    }
236}
237
238fn parse_run_status(s: &str) -> Option<RunStatus> {
239    match s.to_ascii_lowercase().as_str() {
240        "queued" => Some(RunStatus::Queued),
241        "claimed" => Some(RunStatus::Claimed),
242        "running" => Some(RunStatus::Running),
243        "success" => Some(RunStatus::Success),
244        "failed" => Some(RunStatus::Failed),
245        "canceled" => Some(RunStatus::Canceled),
246        "timeout" => Some(RunStatus::Timeout),
247        _ => None,
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use chronon_backend_mem::InMemorySchedulerStore;
255
256    #[tokio::test]
257    async fn upsert_and_run_now() {
258        let store = Arc::new(InMemorySchedulerStore::new());
259        let svc = CoordinatorService::new(store);
260        let job = Job::new("j1", "script_a");
261        svc.upsert_job(job.clone()).await.unwrap();
262        let run_id = svc.run_now(&job.job_id).await.unwrap();
263        assert!(!run_id.is_empty());
264    }
265}