chronon_core/models/job.rs
1//! Job model - represents a scheduled job configuration.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// How a [`Job`] becomes due for enqueue.
8///
9/// | Variant | Due via tick? | Required fields | Typical trigger |
10/// |---------|---------------|-----------------|-----------------|
11/// | [`Self::Cron`] | Yes | `cron_expr` (+ optional `timezone`) | Recurring schedules |
12/// | [`Self::RunOnce`] | Yes | `next_run_at` or `run_once_at` | One-shot deferred work |
13/// | [`Self::Manual`] | **No** | โ | `CoordinatorService::run_now` or HTTP `POST /jobs/run_now` |
14///
15/// Persist via coordinator upsert; cron next-fire is computed there when
16/// `schedule_kind == Cron`. See the `chronon` facade getting-started ยง5.
17///
18/// # Examples
19///
20/// ```
21/// use chronon_core::{Job, ScheduleKind};
22///
23/// let mut cron = Job::new("nightly", "cleanup");
24/// cron.schedule_kind = ScheduleKind::Cron;
25/// cron.cron_expr = Some("0 2 * * *".into());
26/// cron.timezone = Some("UTC".into());
27///
28/// let mut once = Job::new("migrate-once", "migrate");
29/// once.schedule_kind = ScheduleKind::RunOnce;
30/// once.next_run_at = Some(chrono::Utc::now());
31///
32/// let mut manual = Job::new("cleanup-now", "cleanup");
33/// manual.schedule_kind = ScheduleKind::Manual;
34/// assert_eq!(manual.schedule_kind, ScheduleKind::Manual);
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
37#[serde(rename_all = "snake_case")]
38pub enum ScheduleKind {
39 /// Recurring cron schedule โ set [`Job::cron_expr`] and optional [`Job::timezone`].
40 #[default]
41 Cron,
42 /// One-time execution when [`Job::next_run_at`] / [`Job::run_once_at`] is due.
43 RunOnce,
44 /// Never due for the tick loop โ enqueue only via `run_now` (in-process or HTTP).
45 Manual,
46}
47
48/// Retry policy for failed or timed-out runs.
49///
50/// `max_attempts` is the number of **additional** retries after the first attempt
51/// (attempt 1). `0` means no retries.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RetryPolicy {
54 /// Maximum number of retry attempts after the first execution.
55 pub max_attempts: u32,
56
57 /// Base delay between retries in milliseconds.
58 pub base_delay_ms: u64,
59
60 /// Exponential backoff multiplier (`1.0` = no backoff).
61 pub backoff_multiplier: f64,
62
63 /// Cap on delay in milliseconds; `0` means uncapped.
64 pub max_delay_ms: u64,
65}
66
67impl Default for RetryPolicy {
68 fn default() -> Self {
69 Self {
70 max_attempts: 0,
71 base_delay_ms: 0,
72 backoff_multiplier: 1.0,
73 max_delay_ms: 0,
74 }
75 }
76}
77
78impl RetryPolicy {
79 /// Whether a failed attempt with this `attempt` number should schedule another run.
80 pub fn should_retry(&self, attempt: i32) -> bool {
81 attempt > 0 && (attempt as u32) <= self.max_attempts
82 }
83
84 /// Delay before the next attempt after `failed_attempt` finishes.
85 pub fn delay_ms_after(&self, failed_attempt: i32) -> u64 {
86 let exp = failed_attempt.saturating_sub(1).max(0);
87 let raw = (self.base_delay_ms as f64) * self.backoff_multiplier.powi(exp);
88 let ms = if raw.is_finite() && raw > 0.0 {
89 raw.min(u64::MAX as f64) as u64
90 } else {
91 0
92 };
93 if self.max_delay_ms == 0 {
94 ms
95 } else {
96 ms.min(self.max_delay_ms)
97 }
98 }
99}
100
101/// Policy for handling missed scheduled fires at tick time.
102///
103/// When `max_misfire_window_secs == 0`, misfire gating is disabled (legacy: always enqueue).
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct MisfirePolicy {
106 /// When true and within the misfire window, enqueue one coalesced run for the miss.
107 pub run_immediately: bool,
108
109 /// Max lateness (seconds) that still qualifies for misfire recovery; `0` disables gating.
110 pub max_misfire_window_secs: u64,
111}
112
113/// Scheduled work unit: binds a **script name** to a [`ScheduleKind`] and params.
114///
115/// Create with [`Job::new`], set schedule fields, then persist with
116/// `CoordinatorService::upsert_job` (or HTTP upsert / `RemoteCoordinatorClient`).
117/// Typed defaults: [`crate::ScriptHandle`].
118///
119/// | Field group | Purpose |
120/// |-------------|---------|
121/// | `script_name` / `params_json` | What to run and with which args |
122/// | `schedule_kind` + cron / run-once fields | When it becomes due |
123/// | `actor_json` | Identity restored by [`crate::ContextFactory`] at dispatch |
124/// | `pool` / placement | Worker pool targeting in split deployments |
125///
126/// # Examples
127///
128/// ```
129/// use chronon_core::{Job, ScheduleKind};
130///
131/// let mut job = Job::new("nightly-cleanup", "nightly_cleanup");
132/// job.schedule_kind = ScheduleKind::Cron;
133/// job.cron_expr = Some("0 2 * * *".into());
134/// job.timezone = Some("UTC".into());
135/// job.params_json = serde_json::json!({ "retention_days": 7 });
136/// assert!(job.enabled);
137/// assert_eq!(job.script_name, "nightly_cleanup");
138/// ```
139///
140/// Runnable: `cargo run -p uf-chronon --example script_macro --features mem`.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Job {
143 /// Unique identifier (UUID).
144 pub job_id: String,
145
146 /// Human-readable name, unique in the deployment.
147 pub job_name: String,
148
149 /// Name of the script to execute.
150 pub script_name: String,
151
152 /// Signature hash at job creation (for validation).
153 pub script_sig_hash: String,
154
155 /// Whether the job is enabled.
156 pub enabled: bool,
157
158 /// How the job is scheduled.
159 pub schedule_kind: ScheduleKind,
160
161 /// Cron expression (when schedule_kind is Cron).
162 pub cron_expr: Option<String>,
163
164 /// Timezone for cron evaluation (e.g., "America/New_York").
165 pub timezone: Option<String>,
166
167 /// One-time execution timestamp (when schedule_kind is RunOnce).
168 pub run_once_at: Option<DateTime<Utc>>,
169
170 /// When a coordinator claimed this run-once job for enqueue (distributed safety).
171 pub run_once_claimed_at: Option<DateTime<Utc>>,
172
173 /// Coordinator instance id that holds the claim (`coordinator_instance_id`).
174 pub run_once_claimed_by: Option<String>,
175
176 /// Set after a scheduled run-once execution is successfully enqueued (persisted run row).
177 pub run_once_completed_at: Option<DateTime<Utc>>,
178
179 /// Claim lease expiry; after this, another coordinator may reclaim if not completed.
180 pub run_once_claim_expires_at: Option<DateTime<Utc>>,
181
182 /// Partition hash for coordinator sharding (distributed mode).
183 pub partition_hash: Option<i64>,
184
185 /// Coordinator tick-claim holder id.
186 pub claim_lease_id: Option<String>,
187
188 /// Coordinator tick-claim lease expiry.
189 pub claim_lease_until: Option<DateTime<Utc>>,
190
191 // Distributed-mode fields (stored but ignored in local mode)
192 /// Execution pool (e.g., "global", "region/us-west").
193 pub pool: Option<String>,
194
195 /// Target region for execution.
196 pub region: Option<String>,
197
198 /// Additional placement constraints as JSON.
199 pub placement_json: Option<Value>,
200
201 // Identity for permission reconstruction
202 /// Serialized Actor for identity reconstruction.
203 pub actor_json: Value,
204
205 /// Parameters to pass to the script.
206 pub params_json: Value,
207
208 /// Maximum concurrent runs allowed.
209 pub concurrency: i32,
210
211 /// Execution timeout in milliseconds.
212 pub timeout_ms: Option<i64>,
213
214 /// Retry policy JSON ([`RetryPolicy`]).
215 pub retry_policy_json: Value,
216
217 /// Misfire policy JSON ([`MisfirePolicy`]).
218 pub misfire_policy_json: Value,
219
220 /// Limits for parent/child runs.
221 pub parent_limits_json: Option<Value>,
222
223 /// When the job should next run.
224 pub next_run_at: Option<DateTime<Utc>>,
225
226 /// Current revision number.
227 pub current_revision: i32,
228
229 /// Last modification timestamp.
230 pub updated_at: DateTime<Utc>,
231
232 /// Creation timestamp.
233 pub created_at: DateTime<Utc>,
234}
235
236impl Job {
237 /// Baseline job with generated `job_id`, `enabled = true`, and
238 /// [`ScheduleKind::Cron`] (no expression yet).
239 ///
240 /// Populate `schedule_kind`, cron / run-once fields, `params_json`, and
241 /// `actor_json` before upsert. Prefer [`crate::ScriptHandle::job`] /
242 /// [`crate::ScriptHandle::job_with_params`] when the script macro is in use.
243 ///
244 /// # Examples
245 ///
246 /// ```
247 /// use chronon_core::Job;
248 ///
249 /// let job = Job::new("demo", "noop");
250 /// assert!(!job.job_id.is_empty());
251 /// assert_eq!(job.job_name, "demo");
252 /// assert_eq!(job.script_name, "noop");
253 /// ```
254 pub fn new(job_name: impl Into<String>, script_name: impl Into<String>) -> Self {
255 let now = Utc::now();
256 Self {
257 job_id: uuid::Uuid::new_v4().to_string(),
258 job_name: job_name.into(),
259 script_name: script_name.into(),
260 script_sig_hash: String::new(),
261 enabled: true,
262 schedule_kind: ScheduleKind::default(),
263 cron_expr: None,
264 timezone: None,
265 run_once_at: None,
266 run_once_claimed_at: None,
267 run_once_claimed_by: None,
268 run_once_completed_at: None,
269 run_once_claim_expires_at: None,
270 partition_hash: None,
271 claim_lease_id: None,
272 claim_lease_until: None,
273 pool: None,
274 region: None,
275 placement_json: None,
276 actor_json: Value::Null,
277 params_json: Value::Object(serde_json::Map::default()),
278 concurrency: 1,
279 timeout_ms: None,
280 retry_policy_json: serde_json::to_value(RetryPolicy::default()).unwrap_or_default(),
281 misfire_policy_json: serde_json::to_value(MisfirePolicy::default()).unwrap_or_default(),
282 parent_limits_json: None,
283 next_run_at: None,
284 current_revision: 1,
285 updated_at: now,
286 created_at: now,
287 }
288 }
289
290 /// Decode [`RetryPolicy`] from [`Self::retry_policy_json`], or default on null/invalid.
291 pub fn retry_policy(&self) -> RetryPolicy {
292 serde_json::from_value(self.retry_policy_json.clone()).unwrap_or_default()
293 }
294
295 /// Decode [`MisfirePolicy`] from [`Self::misfire_policy_json`], or default on null/invalid.
296 pub fn misfire_policy(&self) -> MisfirePolicy {
297 serde_json::from_value(self.misfire_policy_json.clone()).unwrap_or_default()
298 }
299
300 /// Persist a typed retry policy into [`Self::retry_policy_json`].
301 pub fn set_retry_policy(&mut self, policy: &RetryPolicy) {
302 self.retry_policy_json = serde_json::to_value(policy).unwrap_or_default();
303 }
304
305 /// Persist a typed misfire policy into [`Self::misfire_policy_json`].
306 pub fn set_misfire_policy(&mut self, policy: &MisfirePolicy) {
307 self.misfire_policy_json = serde_json::to_value(policy).unwrap_or_default();
308 }
309}
310
311#[cfg(test)]
312mod policy_tests {
313 use super::*;
314
315 #[test]
316 fn retry_default_does_not_retry() {
317 let p = RetryPolicy::default();
318 assert!(!p.should_retry(1));
319 assert_eq!(p.delay_ms_after(1), 0);
320 }
321
322 #[test]
323 fn retry_backoff_and_cap() {
324 let p = RetryPolicy {
325 max_attempts: 3,
326 base_delay_ms: 100,
327 backoff_multiplier: 2.0,
328 max_delay_ms: 250,
329 };
330 assert!(p.should_retry(1));
331 assert!(p.should_retry(3));
332 assert!(!p.should_retry(4));
333 assert_eq!(p.delay_ms_after(1), 100);
334 assert_eq!(p.delay_ms_after(2), 200);
335 assert_eq!(p.delay_ms_after(3), 250);
336 }
337
338 #[test]
339 fn job_policy_roundtrip() {
340 let mut job = Job::new("n", "s");
341 let retry = RetryPolicy {
342 max_attempts: 2,
343 base_delay_ms: 50,
344 backoff_multiplier: 1.5,
345 max_delay_ms: 500,
346 };
347 let misfire = MisfirePolicy {
348 run_immediately: true,
349 max_misfire_window_secs: 3600,
350 };
351 job.set_retry_policy(&retry);
352 job.set_misfire_policy(&misfire);
353 assert_eq!(job.retry_policy().max_attempts, 2);
354 assert!(job.misfire_policy().run_immediately);
355 assert_eq!(job.misfire_policy().max_misfire_window_secs, 3600);
356 }
357}