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}