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 the job is scheduled.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum ScheduleKind {
11    /// Recurring cron schedule.
12    #[default]
13    Cron,
14    /// One-time execution at a specific time.
15    RunOnce,
16    /// Manual execution only (no automatic scheduling).
17    Manual,
18}
19
20/// Retry policy for failed or timed-out runs.
21///
22/// `max_attempts` is the number of **additional** retries after the first attempt
23/// (attempt 1). `0` means no retries.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RetryPolicy {
26    /// Maximum number of retry attempts after the first execution.
27    pub max_attempts: u32,
28
29    /// Base delay between retries in milliseconds.
30    pub base_delay_ms: u64,
31
32    /// Exponential backoff multiplier (`1.0` = no backoff).
33    pub backoff_multiplier: f64,
34
35    /// Cap on delay in milliseconds; `0` means uncapped.
36    pub max_delay_ms: u64,
37}
38
39impl Default for RetryPolicy {
40    fn default() -> Self {
41        Self {
42            max_attempts: 0,
43            base_delay_ms: 0,
44            backoff_multiplier: 1.0,
45            max_delay_ms: 0,
46        }
47    }
48}
49
50impl RetryPolicy {
51    /// Whether a failed attempt with this `attempt` number should schedule another run.
52    pub fn should_retry(&self, attempt: i32) -> bool {
53        attempt > 0 && (attempt as u32) <= self.max_attempts
54    }
55
56    /// Delay before the next attempt after `failed_attempt` finishes.
57    pub fn delay_ms_after(&self, failed_attempt: i32) -> u64 {
58        let exp = failed_attempt.saturating_sub(1).max(0);
59        let raw = (self.base_delay_ms as f64) * self.backoff_multiplier.powi(exp);
60        let ms = if raw.is_finite() && raw > 0.0 {
61            raw.min(u64::MAX as f64) as u64
62        } else {
63            0
64        };
65        if self.max_delay_ms == 0 {
66            ms
67        } else {
68            ms.min(self.max_delay_ms)
69        }
70    }
71}
72
73/// Policy for handling missed scheduled fires at tick time.
74///
75/// When `max_misfire_window_secs == 0`, misfire gating is disabled (legacy: always enqueue).
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct MisfirePolicy {
78    /// When true and within the misfire window, enqueue one coalesced run for the miss.
79    pub run_immediately: bool,
80
81    /// Max lateness (seconds) that still qualifies for misfire recovery; `0` disables gating.
82    pub max_misfire_window_secs: u64,
83}
84
85/// A scheduled job configuration.
86///
87/// Jobs reference a script and define when/how it should run.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Job {
90    /// Unique identifier (UUID).
91    pub job_id: String,
92
93    /// Human-readable name, unique in the deployment.
94    pub job_name: String,
95
96    /// Name of the script to execute.
97    pub script_name: String,
98
99    /// Signature hash at job creation (for validation).
100    pub script_sig_hash: String,
101
102    /// Whether the job is enabled.
103    pub enabled: bool,
104
105    /// How the job is scheduled.
106    pub schedule_kind: ScheduleKind,
107
108    /// Cron expression (when schedule_kind is Cron).
109    pub cron_expr: Option<String>,
110
111    /// Timezone for cron evaluation (e.g., "America/New_York").
112    pub timezone: Option<String>,
113
114    /// One-time execution timestamp (when schedule_kind is RunOnce).
115    pub run_once_at: Option<DateTime<Utc>>,
116
117    /// When a coordinator claimed this run-once job for enqueue (distributed safety).
118    pub run_once_claimed_at: Option<DateTime<Utc>>,
119
120    /// Coordinator instance id that holds the claim (`coordinator_instance_id`).
121    pub run_once_claimed_by: Option<String>,
122
123    /// Set after a scheduled run-once execution is successfully enqueued (persisted run row).
124    pub run_once_completed_at: Option<DateTime<Utc>>,
125
126    /// Claim lease expiry; after this, another coordinator may reclaim if not completed.
127    pub run_once_claim_expires_at: Option<DateTime<Utc>>,
128
129    /// Partition hash for coordinator sharding (distributed mode).
130    pub partition_hash: Option<i64>,
131
132    /// Coordinator tick-claim holder id.
133    pub claim_lease_id: Option<String>,
134
135    /// Coordinator tick-claim lease expiry.
136    pub claim_lease_until: Option<DateTime<Utc>>,
137
138    // Distributed-mode fields (stored but ignored in local mode)
139    /// Execution pool (e.g., "global", "region/us-west").
140    pub pool: Option<String>,
141
142    /// Target region for execution.
143    pub region: Option<String>,
144
145    /// Additional placement constraints as JSON.
146    pub placement_json: Option<Value>,
147
148    // Identity for permission reconstruction
149    /// Serialized Actor for identity reconstruction.
150    pub actor_json: Value,
151
152    /// Parameters to pass to the script.
153    pub params_json: Value,
154
155    /// Maximum concurrent runs allowed.
156    pub concurrency: i32,
157
158    /// Execution timeout in milliseconds.
159    pub timeout_ms: Option<i64>,
160
161    /// Retry policy JSON ([`RetryPolicy`]).
162    pub retry_policy_json: Value,
163
164    /// Misfire policy JSON ([`MisfirePolicy`]).
165    pub misfire_policy_json: Value,
166
167    /// Limits for parent/child runs.
168    pub parent_limits_json: Option<Value>,
169
170    /// When the job should next run.
171    pub next_run_at: Option<DateTime<Utc>>,
172
173    /// Current revision number.
174    pub current_revision: i32,
175
176    /// Last modification timestamp.
177    pub updated_at: DateTime<Utc>,
178
179    /// Creation timestamp.
180    pub created_at: DateTime<Utc>,
181}
182
183impl Job {
184    /// Create a baseline job record with generated IDs and defaults.
185    ///
186    /// This constructor intentionally leaves identity and advanced scheduling
187    /// fields in default/empty form. Populate cron, actor, and params fields
188    /// before persistence (via coordinator service, HTTP upsert API, or direct store calls).
189    pub fn new(job_name: impl Into<String>, script_name: impl Into<String>) -> Self {
190        let now = Utc::now();
191        Self {
192            job_id: uuid::Uuid::new_v4().to_string(),
193            job_name: job_name.into(),
194            script_name: script_name.into(),
195            script_sig_hash: String::new(),
196            enabled: true,
197            schedule_kind: ScheduleKind::default(),
198            cron_expr: None,
199            timezone: None,
200            run_once_at: None,
201            run_once_claimed_at: None,
202            run_once_claimed_by: None,
203            run_once_completed_at: None,
204            run_once_claim_expires_at: None,
205            partition_hash: None,
206            claim_lease_id: None,
207            claim_lease_until: None,
208            pool: None,
209            region: None,
210            placement_json: None,
211            actor_json: Value::Null,
212            params_json: Value::Object(serde_json::Map::default()),
213            concurrency: 1,
214            timeout_ms: None,
215            retry_policy_json: serde_json::to_value(RetryPolicy::default()).unwrap_or_default(),
216            misfire_policy_json: serde_json::to_value(MisfirePolicy::default()).unwrap_or_default(),
217            parent_limits_json: None,
218            next_run_at: None,
219            current_revision: 1,
220            updated_at: now,
221            created_at: now,
222        }
223    }
224
225    /// Decode [`RetryPolicy`] from [`Self::retry_policy_json`], or default on null/invalid.
226    pub fn retry_policy(&self) -> RetryPolicy {
227        serde_json::from_value(self.retry_policy_json.clone()).unwrap_or_default()
228    }
229
230    /// Decode [`MisfirePolicy`] from [`Self::misfire_policy_json`], or default on null/invalid.
231    pub fn misfire_policy(&self) -> MisfirePolicy {
232        serde_json::from_value(self.misfire_policy_json.clone()).unwrap_or_default()
233    }
234
235    /// Persist a typed retry policy into [`Self::retry_policy_json`].
236    pub fn set_retry_policy(&mut self, policy: &RetryPolicy) {
237        self.retry_policy_json = serde_json::to_value(policy).unwrap_or_default();
238    }
239
240    /// Persist a typed misfire policy into [`Self::misfire_policy_json`].
241    pub fn set_misfire_policy(&mut self, policy: &MisfirePolicy) {
242        self.misfire_policy_json = serde_json::to_value(policy).unwrap_or_default();
243    }
244}
245
246#[cfg(test)]
247mod policy_tests {
248    use super::*;
249
250    #[test]
251    fn retry_default_does_not_retry() {
252        let p = RetryPolicy::default();
253        assert!(!p.should_retry(1));
254        assert_eq!(p.delay_ms_after(1), 0);
255    }
256
257    #[test]
258    fn retry_backoff_and_cap() {
259        let p = RetryPolicy {
260            max_attempts: 3,
261            base_delay_ms: 100,
262            backoff_multiplier: 2.0,
263            max_delay_ms: 250,
264        };
265        assert!(p.should_retry(1));
266        assert!(p.should_retry(3));
267        assert!(!p.should_retry(4));
268        assert_eq!(p.delay_ms_after(1), 100);
269        assert_eq!(p.delay_ms_after(2), 200);
270        assert_eq!(p.delay_ms_after(3), 250);
271    }
272
273    #[test]
274    fn job_policy_roundtrip() {
275        let mut job = Job::new("n", "s");
276        let retry = RetryPolicy {
277            max_attempts: 2,
278            base_delay_ms: 50,
279            backoff_multiplier: 1.5,
280            max_delay_ms: 500,
281        };
282        let misfire = MisfirePolicy {
283            run_immediately: true,
284            max_misfire_window_secs: 3600,
285        };
286        job.set_retry_policy(&retry);
287        job.set_misfire_policy(&misfire);
288        assert_eq!(job.retry_policy().max_attempts, 2);
289        assert!(job.misfire_policy().run_immediately);
290        assert_eq!(job.misfire_policy().max_misfire_window_secs, 3600);
291    }
292}