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::{job_execution_pool_id, partition_hash_i64_for_job_id, CronExpr};
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/// remote-client 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` (remote HTTP client topology).
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.script_name != job.script_name {
106                return Err(ChrononError::ScriptMismatch {
107                    expected: existing.script_name,
108                    actual: job.script_name,
109                    job_name: job.job_name,
110                });
111            }
112            if existing.current_revision != job.current_revision {
113                let revision = JobRevision::new(
114                    &job.job_id,
115                    job.current_revision,
116                    job.actor_json.clone(),
117                    serde_json::to_value(&job)?,
118                );
119                self.store.append_revision(&revision).await?;
120            }
121        } else {
122            let revision = JobRevision::new(
123                &job.job_id,
124                1,
125                job.actor_json.clone(),
126                serde_json::to_value(&job)?,
127            );
128            self.store.append_revision(&revision).await?;
129        }
130
131        self.store.upsert_job(&job).await
132    }
133
134    /// Load a job by stable `job_id`.
135    pub async fn get_job(&self, job_id: &str) -> Option<Job> {
136        self.store.get_job(job_id).await.ok().flatten()
137    }
138
139    /// Load a job by human-readable `job_name`.
140    pub async fn get_job_by_name(&self, job_name: &str) -> Option<Job> {
141        self.store.get_job_by_name(job_name).await.ok().flatten()
142    }
143
144    /// All jobs in the store.
145    ///
146    /// # Errors
147    ///
148    /// Returns a storage error when the underlying store fails.
149    pub async fn list_jobs(&self) -> Result<Vec<Job>> {
150        self.store.list_jobs().await
151    }
152
153    /// Disable scheduling for `job_id` without deleting the row.
154    pub async fn pause_job(&self, job_id: &str) -> Result<()> {
155        self.store.pause_job(job_id).await
156    }
157
158    /// Re-enable scheduling for `job_id`.
159    pub async fn resume_job(&self, job_id: &str) -> Result<()> {
160        self.store.resume_job(job_id).await
161    }
162
163    /// Paginated run listing with optional `job_id` and status string filters.
164    ///
165    /// Unrecognized status strings are ignored (no filter).
166    pub async fn list_runs(
167        &self,
168        job_id: Option<&str>,
169        status: Option<&str>,
170        offset: usize,
171        limit: usize,
172    ) -> Result<Vec<Run>> {
173        let status_filter = status.and_then(parse_run_status);
174        self.store
175            .list_runs_filtered(job_id, status_filter, offset, limit)
176            .await
177    }
178
179    /// Load a single run by `run_id`.
180    pub async fn get_run(&self, run_id: &str) -> Result<Option<Run>> {
181        self.store.get_run(run_id).await
182    }
183
184    /// Revision history for `job_id`, oldest first per store ordering.
185    pub async fn list_revisions(&self, job_id: &str) -> Result<Vec<JobRevision>> {
186        self.store.list_revisions(job_id).await
187    }
188
189    /// Enqueue an immediate run using the job's stored `params_json`.
190    ///
191    /// Returns the new `run_id`. Errors with [`ChrononError::JobNotFound`] when missing.
192    ///
193    /// Manual jobs ([`ScheduleKind::Manual`]) are never due for the tick loop; use this
194    /// (or HTTP `POST /jobs/run_now`) to trigger them. Works for any schedule kind.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use std::sync::Arc;
200    /// use chronon_backend_mem::InMemorySchedulerStore;
201    /// use chronon_core::{Job, ScheduleKind};
202    /// use chronon_runtime::CoordinatorService;
203    ///
204    /// # #[tokio::main]
205    /// # async fn main() -> chronon_core::Result<()> {
206    /// let coordinator = CoordinatorService::new(Arc::new(InMemorySchedulerStore::new()));
207    /// let mut job = Job::new("probe", "noop");
208    /// job.schedule_kind = ScheduleKind::Manual;
209    /// coordinator.upsert_job(job.clone()).await?;
210    /// let run_id = coordinator.run_now(&job.job_id).await?;
211    /// assert!(!run_id.is_empty());
212    /// # Ok(())
213    /// # }
214    /// ```
215    ///
216    /// Runnable sample: `cargo run -p uf-chronon --example run_now --features mem`.
217    pub async fn run_now(&self, job_id: &str) -> Result<String> {
218        self.run_now_with_params(job_id, None).await
219    }
220
221    /// Enqueue an immediate run, optionally overriding params JSON.
222    pub async fn run_now_with_params(
223        &self,
224        job_id: &str,
225        params_override: Option<Value>,
226    ) -> Result<String> {
227        let Some(job) = self.store.get_job(job_id).await? else {
228            return Err(ChrononError::JobNotFound(job_id.to_string()));
229        };
230        let now = Utc::now();
231        let mut run = Run::for_job(&job.job_id, &job.script_name, now);
232        run.actor_json = job.actor_json.clone();
233        run.params_json = params_override.unwrap_or_else(|| job.params_json.clone());
234        run.pool_id = Some(job_execution_pool_id(&job));
235        let run_id = run.run_id.clone();
236        self.store.create_run(&run).await?;
237        Ok(run_id)
238    }
239}
240
241fn parse_run_status(s: &str) -> Option<RunStatus> {
242    match s.to_ascii_lowercase().as_str() {
243        "queued" => Some(RunStatus::Queued),
244        "claimed" => Some(RunStatus::Claimed),
245        "running" => Some(RunStatus::Running),
246        "success" => Some(RunStatus::Success),
247        "failed" => Some(RunStatus::Failed),
248        "canceled" => Some(RunStatus::Canceled),
249        "timeout" => Some(RunStatus::Timeout),
250        _ => None,
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use chronon_backend_mem::InMemorySchedulerStore;
258
259    #[tokio::test]
260    async fn upsert_and_run_now() {
261        let store = Arc::new(InMemorySchedulerStore::new());
262        let svc = CoordinatorService::new(store);
263        let job = Job::new("j1", "script_a");
264        svc.upsert_job(job.clone()).await.unwrap();
265        let run_id = svc.run_now(&job.job_id).await.unwrap();
266        assert!(!run_id.is_empty());
267    }
268}