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}