Skip to main content

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}