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 | Public crate feature | Topology fit |
18/// |---------|----------------|--------------|
19/// | `InMemorySchedulerStore` | `mem` | Embedded local / tests (not multi-process) |
20/// | `SqliteSchedulerStore` | `sqlite` | Embedded single-host durable |
21/// | `PostgresSchedulerStore` | `postgres` | Coordinator–worker shared durable |
22/// | `PostgresRedisSchedulerStore` | `postgres,redis` | Coordinator–worker 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 /// Reset `claimed` / `running` runs whose `claim_lease_until` is at or before `now` to
174 /// [`RunStatus::Queued`].
175 ///
176 /// Clears `claimed_by`, `claim_lease_until`, and `started_at`. Returns the reclaimed
177 /// `run_id` values so hybrid backends can re-enqueue claim queues.
178 async fn reclaim_expired_run_leases(&self, now: DateTime<Utc>) -> Result<Vec<String>>;
179
180 // --- Revisions ---
181
182 /// Append an immutable job revision snapshot (audit / rollback).
183 async fn append_revision(&self, revision: &JobRevision) -> Result<()>;
184
185 /// All revisions for a job, typically oldest-first.
186 async fn list_revisions(&self, job_id: &str) -> Result<Vec<JobRevision>>;
187
188 // --- Scripts ---
189
190 /// Insert or replace script metadata (name, signature hash).
191 async fn upsert_script(&self, script: &Script) -> Result<()>;
192
193 /// Look up persisted script metadata by name.
194 async fn get_script(&self, script_name: &str) -> Result<Option<Script>>;
195
196 // --- Run-once coordinator safety ---
197
198 /// Attempt exclusive claim for a run-once job before enqueueing its single run.
199 ///
200 /// Returns `true` when this `claimed_by` instance acquired the claim; `false` when another
201 /// coordinator already holds it or the job already completed.
202 async fn try_claim_run_once(
203 &self,
204 job_id: &str,
205 claimed_by: &str,
206 now: DateTime<Utc>,
207 claim_ttl_secs: i64,
208 ) -> Result<bool>;
209
210 /// Mark a run-once job as finished so future ticks skip it.
211 async fn mark_run_once_completed(
212 &self,
213 job_id: &str,
214 completed_at: DateTime<Utc>,
215 ) -> Result<()>;
216
217 /// Release a run-once claim when enqueue failed or the coordinator shut down cleanly.
218 async fn release_run_once_claim(
219 &self,
220 job_id: &str,
221 claimed_by: &str,
222 now: DateTime<Utc>,
223 ) -> Result<()>;
224
225 // --- Tick / partition coordinator ---
226
227 /// Due job ids owned by this coordinator's partition slice (distributed tick).
228 async fn find_due_job_ids_in_partitions(
229 &self,
230 owned_partitions: &[u32],
231 due_until: DateTime<Utc>,
232 limit: u32,
233 ) -> Result<Vec<String>>;
234
235 /// Earliest `next_run_at` among jobs in the owned partitions (sleep hint for tick loop).
236 async fn min_next_run_at_in_partitions(
237 &self,
238 owned_partitions: &[u32],
239 ) -> Result<Option<DateTime<Utc>>>;
240
241 /// Exclusive short-lived lease on a job row during tick processing.
242 ///
243 /// Prevents duplicate enqueue when multiple scheduler instances race on the same job.
244 async fn claim_job_for_tick(
245 &self,
246 job_id: &str,
247 claim_id: &str,
248 now: DateTime<Utc>,
249 lease_ttl_secs: i64,
250 ) -> Result<bool>;
251
252 /// Release tick claim after enqueue succeeds or the tick aborts.
253 async fn release_job_tick_claim(&self, job_id: &str) -> Result<()>;
254
255 /// Persist `next_run_at` (and related job fields) after a successful tick.
256 async fn persist_post_tick_job_state(
257 &self,
258 job_id: &str,
259 next_run_at: Option<DateTime<Utc>>,
260 ) -> Result<()>;
261
262 // --- Scheduler leader ---
263
264 /// Attempt to become the active scheduler leader (singleton row + TTL).
265 async fn try_acquire_leader(&self, instance_id: &str, ttl_secs: i64) -> Result<bool>;
266
267 /// Renew the leader lease while this instance remains leader.
268 async fn renew_leader_lease(&self, instance_id: &str, ttl_secs: i64) -> Result<()>;
269
270 /// Current leader row, if any (expired leases may still be returned for diagnostics).
271 async fn get_leader(&self) -> Result<Option<SchedulerLeader>>;
272
273 // --- Partitions / workers ---
274
275 /// Upsert partition ownership for coordinator sharding.
276 async fn upsert_partition_assignment(&self, assignment: &PartitionAssignment) -> Result<()>;
277
278 /// All partition assignments (rebalance / diagnostics).
279 async fn list_partition_assignments(&self) -> Result<Vec<PartitionAssignment>>;
280
281 /// Register or update a worker heartbeat row.
282 async fn register_worker(&self, worker: &Worker) -> Result<()>;
283
284 /// Update `last_heartbeat_at` for an existing worker.
285 async fn heartbeat_worker(&self, worker_id: &str, at: DateTime<Utc>) -> Result<()>;
286}