chronon_core/store.rs
1//! Scheduler persistence port for jobs, runs, revisions, and coordinator metadata.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5
6use crate::error::Result;
7use crate::models::{
8 Job, JobRevision, PartitionAssignment, Run, RunStatus, SchedulerLeader, Script, Worker,
9};
10
11/// Async persistence port for jobs, runs, revisions, and coordinator metadata.
12///
13/// Hosts provide one implementation per storage substrate. The scheduler, executor, and
14/// HTTP API call these methods; implementations must be `Send + Sync` for shared use across
15/// Tokio tasks.
16///
17/// | Adapter | Facade feature | Topology fit |
18/// |---------|----------------|--------------|
19/// | `InMemorySchedulerStore` | `mem` | Mode 1 local / tests (not multi-process) |
20/// | `SqliteSchedulerStore` | `sqlite` | Mode 1 single-host durable |
21/// | `PostgresSchedulerStore` | `postgres` | Mode 2 shared durable |
22/// | `PostgresRedisSchedulerStore` | `postgres,redis` | Mode 2 production claim path |
23///
24/// Inject via `ChrononBuilder::scheduler_store` (or [`crate::StoreRouter`] +
25/// `scheduler_store_from_global`). Custom adapters implement this trait in a separate crate.
26///
27/// # Contract
28///
29/// - Job rows are keyed by [`Job::job_id`]; [`Job::job_name`] is unique per deployment.
30/// - Run claims must be atomic: at most one worker holds a claimed run at a time.
31/// - Tick claims prevent duplicate enqueue when coordinators race on the same job.
32/// - Leader election uses a singleton row with TTL renewal semantics.
33///
34/// # Examples
35///
36/// Trait-object usage (pass any adapter that implements the port):
37///
38/// ```
39/// use std::sync::Arc;
40/// use chronon_core::{Job, SchedulerStore};
41///
42/// async fn seed(store: Arc<dyn SchedulerStore>) -> chronon_core::Result<()> {
43/// store.upsert_job(&Job::new("demo", "noop")).await?;
44/// assert_eq!(store.list_jobs().await?.len(), 1);
45/// Ok(())
46/// }
47/// ```
48///
49/// Concrete adapters and runnable boots live in `chronon-backend-mem` / `-sqlite` /
50/// `-postgres` / `-redis` and the `uf-chronon` examples (`sqlite_boot`, `postgres_boot`, …).
51#[async_trait]
52pub trait SchedulerStore: Send + Sync {
53 // --- Jobs ---
54
55 /// Insert or replace a job row keyed by [`Job::job_id`].
56 ///
57 /// # Contract
58 ///
59 /// Replaces the full row; callers must send complete job state on update.
60 async fn upsert_job(&self, job: &Job) -> Result<()>;
61
62 /// Look up a job by primary key.
63 async fn get_job(&self, job_id: &str) -> Result<Option<Job>>;
64
65 /// Look up a job by unique [`Job::job_name`].
66 async fn get_job_by_name(&self, job_name: &str) -> Result<Option<Job>>;
67
68 /// Return all jobs (admin / list API).
69 async fn list_jobs(&self) -> Result<Vec<Job>>;
70
71 /// Jobs with `next_run_at <= before` and enabled scheduling (tick discovery).
72 ///
73 /// # Contract
74 ///
75 /// Returns only enabled jobs whose `next_run_at` is set and `<= before`.
76 async fn list_due_jobs(&self, before: DateTime<Utc>) -> Result<Vec<Job>>;
77
78 /// Disable automatic scheduling without deleting the job.
79 async fn pause_job(&self, job_id: &str) -> Result<()>;
80
81 /// Re-enable automatic scheduling after [`Self::pause_job`].
82 async fn resume_job(&self, job_id: &str) -> Result<()>;
83
84 // --- Runs ---
85
86 /// Persist a new run row (typically `RunStatus::Queued`).
87 ///
88 /// # Contract
89 ///
90 /// `run_id` must be unique; duplicate inserts are a backend error.
91 async fn create_run(&self, run: &Run) -> Result<()>;
92
93 /// Replace an existing run row (status transitions, lease renewal, completion).
94 async fn update_run(&self, run: &Run) -> Result<()>;
95
96 /// Look up a run by [`Run::run_id`].
97 async fn get_run(&self, run_id: &str) -> Result<Option<Run>>;
98
99 /// Recent runs for one job, newest first, capped by `limit`.
100 async fn list_runs_for_job(&self, job_id: &str, limit: usize) -> Result<Vec<Run>>;
101
102 /// Paginated run listing with optional job and status filters (HTTP list API).
103 async fn list_runs_filtered(
104 &self,
105 job_id: Option<&str>,
106 status: Option<RunStatus>,
107 offset: usize,
108 limit: usize,
109 ) -> Result<Vec<Run>>;
110
111 /// Claim the next queued run for a worker pool.
112 ///
113 /// # Contract
114 ///
115 /// Atomically selects a `Queued` run matching `pool_id`, sets `claimed_by`, and writes
116 /// `claim_lease_until = now + lease_ttl_secs`. Returns `None` when the pool queue is empty.
117 /// At most one worker may hold a given run claim at a time.
118 async fn claim_next_queued(
119 &self,
120 pool_id: &str,
121 worker_id: &str,
122 now: DateTime<Utc>,
123 lease_ttl_secs: i64,
124 ) -> Result<Option<Run>>;
125
126 /// Claim a specific queued run by id (postgres-redis hybrid hot path).
127 ///
128 /// Returns `None` when the run is not queued, pool/lease checks fail, or the row
129 /// does not exist.
130 async fn claim_run_by_id(
131 &self,
132 run_id: &str,
133 pool_id: &str,
134 worker_id: &str,
135 now: DateTime<Utc>,
136 lease_ttl_secs: i64,
137 ) -> Result<Option<Run>>;
138
139 /// Claim multiple queued runs by id in one round trip when the backend supports it.
140 ///
141 /// Default implementation claims each id sequentially via [`Self::claim_run_by_id`].
142 async fn claim_runs_by_ids(
143 &self,
144 run_ids: &[&str],
145 pool_id: &str,
146 worker_id: &str,
147 now: DateTime<Utc>,
148 lease_ttl_secs: i64,
149 ) -> Result<Vec<Run>> {
150 let mut claimed = Vec::new();
151 for run_id in run_ids {
152 if let Some(run) = self
153 .claim_run_by_id(run_id, pool_id, worker_id, now, lease_ttl_secs)
154 .await?
155 {
156 claimed.push(run);
157 }
158 }
159 Ok(claimed)
160 }
161
162 /// Extend the worker lease on a claimed run if `worker_id` still holds the claim.
163 ///
164 /// Returns `false` when the run is not claimed by this worker or the lease expired.
165 async fn renew_run_lease(
166 &self,
167 run_id: &str,
168 worker_id: &str,
169 now: DateTime<Utc>,
170 lease_ttl_secs: i64,
171 ) -> Result<bool>;
172
173 // --- Revisions ---
174
175 /// Append an immutable job revision snapshot (audit / rollback).
176 async fn append_revision(&self, revision: &JobRevision) -> Result<()>;
177
178 /// All revisions for a job, typically oldest-first.
179 async fn list_revisions(&self, job_id: &str) -> Result<Vec<JobRevision>>;
180
181 // --- Scripts ---
182
183 /// Insert or replace script metadata (name, signature hash).
184 async fn upsert_script(&self, script: &Script) -> Result<()>;
185
186 /// Look up persisted script metadata by name.
187 async fn get_script(&self, script_name: &str) -> Result<Option<Script>>;
188
189 // --- Run-once coordinator safety ---
190
191 /// Attempt exclusive claim for a run-once job before enqueueing its single run.
192 ///
193 /// Returns `true` when this `claimed_by` instance acquired the claim; `false` when another
194 /// coordinator already holds it or the job already completed.
195 async fn try_claim_run_once(
196 &self,
197 job_id: &str,
198 claimed_by: &str,
199 now: DateTime<Utc>,
200 claim_ttl_secs: i64,
201 ) -> Result<bool>;
202
203 /// Mark a run-once job as finished so future ticks skip it.
204 async fn mark_run_once_completed(&self, job_id: &str, completed_at: DateTime<Utc>)
205 -> Result<()>;
206
207 /// Release a run-once claim when enqueue failed or the coordinator shut down cleanly.
208 async fn release_run_once_claim(
209 &self,
210 job_id: &str,
211 claimed_by: &str,
212 now: DateTime<Utc>,
213 ) -> Result<()>;
214
215 // --- Tick / partition coordinator ---
216
217 /// Due job ids owned by this coordinator's partition slice (distributed tick).
218 async fn find_due_job_ids_in_partitions(
219 &self,
220 owned_partitions: &[u32],
221 due_until: DateTime<Utc>,
222 limit: u32,
223 ) -> Result<Vec<String>>;
224
225 /// Earliest `next_run_at` among jobs in the owned partitions (sleep hint for tick loop).
226 async fn min_next_run_at_in_partitions(
227 &self,
228 owned_partitions: &[u32],
229 ) -> Result<Option<DateTime<Utc>>>;
230
231 /// Exclusive short-lived lease on a job row during tick processing.
232 ///
233 /// Prevents duplicate enqueue when multiple scheduler instances race on the same job.
234 async fn claim_job_for_tick(
235 &self,
236 job_id: &str,
237 claim_id: &str,
238 now: DateTime<Utc>,
239 lease_ttl_secs: i64,
240 ) -> Result<bool>;
241
242 /// Release tick claim after enqueue succeeds or the tick aborts.
243 async fn release_job_tick_claim(&self, job_id: &str) -> Result<()>;
244
245 /// Persist `next_run_at` (and related job fields) after a successful tick.
246 async fn persist_post_tick_job_state(
247 &self,
248 job_id: &str,
249 next_run_at: Option<DateTime<Utc>>,
250 ) -> Result<()>;
251
252 // --- Scheduler leader ---
253
254 /// Attempt to become the active scheduler leader (singleton row + TTL).
255 async fn try_acquire_leader(&self, instance_id: &str, ttl_secs: i64) -> Result<bool>;
256
257 /// Renew the leader lease while this instance remains leader.
258 async fn renew_leader_lease(&self, instance_id: &str, ttl_secs: i64) -> Result<()>;
259
260 /// Current leader row, if any (expired leases may still be returned for diagnostics).
261 async fn get_leader(&self) -> Result<Option<SchedulerLeader>>;
262
263 // --- Partitions / workers ---
264
265 /// Upsert partition ownership for coordinator sharding.
266 async fn upsert_partition_assignment(&self, assignment: &PartitionAssignment) -> Result<()>;
267
268 /// All partition assignments (rebalance / diagnostics).
269 async fn list_partition_assignments(&self) -> Result<Vec<PartitionAssignment>>;
270
271 /// Register or update a worker heartbeat row.
272 async fn register_worker(&self, worker: &Worker) -> Result<()>;
273
274 /// Update `last_heartbeat_at` for an existing worker.
275 async fn heartbeat_worker(&self, worker_id: &str, at: DateTime<Utc>) -> Result<()>;
276}