1use super::JobConfig;
2use crate::features;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct ScheduleDefinition {
9 pub name: String,
10 pub handler: String,
11 pub spec: ScheduleSpec,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub timezone: Option<String>,
15 #[serde(default, skip_serializing_if = "Overlap::is_skip")]
16 pub overlap: Overlap,
17 #[serde(default, skip_serializing_if = "CatchUp::is_latest")]
18 pub catch_up: CatchUp,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub job: Option<JobConfig>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub timeout_secs: Option<u32>,
25}
26
27impl ScheduleDefinition {
28 pub const NAME_MAX: usize = 64;
29 pub const DEFAULT_TIMEOUT_SECS: u32 = 60;
30 pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=300;
32 pub const MIN_INTERVAL_SECS: u32 = 10;
34
35 pub fn new(name: impl Into<String>, handler: impl Into<String>, spec: ScheduleSpec) -> Self {
38 Self {
39 name: name.into(),
40 handler: handler.into(),
41 spec,
42 timezone: None,
43 overlap: Overlap::default(),
44 catch_up: CatchUp::default(),
45 job: None,
46 timeout_secs: None,
47 }
48 }
49
50 pub fn is_valid_name(name: &str) -> bool {
52 (1..=Self::NAME_MAX).contains(&name.len())
53 && name
54 .bytes()
55 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
56 }
57
58 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
61 self.job
62 .as_ref()
63 .is_some_and(JobConfig::uses_v2)
64 .then_some(features::JOBS_V2)
65 .into_iter()
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71pub enum ScheduleSpec {
72 Cron { expr: String },
74 Every { secs: u32 },
76}
77
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Overlap {
82 #[default]
83 Skip,
84 Allow,
85}
86
87impl Overlap {
88 pub fn is_skip(&self) -> bool {
89 *self == Overlap::Skip
90 }
91}
92
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum CatchUp {
97 #[default]
99 Latest,
100 None,
101 All,
103}
104
105impl CatchUp {
106 pub fn is_latest(&self) -> bool {
107 *self == CatchUp::Latest
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::{BackoffSpec, RetryOn};
115 use serde_json::json;
116
117 fn every(secs: u32) -> ScheduleDefinition {
118 ScheduleDefinition {
119 name: "order_retry".into(),
120 handler: "retry_orders".into(),
121 spec: ScheduleSpec::Every { secs },
122 timezone: None,
123 overlap: Overlap::Skip,
124 catch_up: CatchUp::Latest,
125 job: None,
126 timeout_secs: None,
127 }
128 }
129
130 #[test]
131 fn a_minimal_schedule_carries_only_what_it_declares() {
132 let wire = json!({
133 "name": "order_retry",
134 "handler": "retry_orders",
135 "spec": {"kind": "every", "secs": 30}
136 });
137 assert_eq!(serde_json::to_value(every(30)).unwrap(), wire);
138 assert_eq!(
139 serde_json::from_value::<ScheduleDefinition>(wire).unwrap(),
140 every(30)
141 );
142 assert_eq!(every(30).derived_features().count(), 0);
143 }
144
145 #[test]
146 fn every_field_round_trips() {
147 let schedule = ScheduleDefinition {
148 name: "payment_reminders".into(),
149 handler: "run_payment_reminders".into(),
150 spec: ScheduleSpec::Cron {
151 expr: "0 * * * *".into(),
152 },
153 timezone: Some("Europe/London".into()),
154 overlap: Overlap::Allow,
155 catch_up: CatchUp::None,
156 job: Some(JobConfig {
157 timeout_secs: 900,
158 ..Default::default()
159 }),
160 timeout_secs: Some(120),
161 };
162 let wire = json!({
163 "name": "payment_reminders",
164 "handler": "run_payment_reminders",
165 "spec": {"kind": "cron", "expr": "0 * * * *"},
166 "timezone": "Europe/London",
167 "overlap": "allow",
168 "catch_up": "none",
169 "job": {"timeout_secs": 900, "max_attempts": 3},
170 "timeout_secs": 120
171 });
172 assert_eq!(serde_json::to_value(&schedule).unwrap(), wire);
173 assert_eq!(
174 serde_json::from_value::<ScheduleDefinition>(wire).unwrap(),
175 schedule
176 );
177 assert_eq!(serde_json::to_value(CatchUp::All).unwrap(), json!("all"));
178 }
179
180 #[test]
181 fn a_retrying_job_demands_jobs_v2() {
182 let plain = ScheduleDefinition {
183 job: Some(JobConfig::default()),
184 ..every(60)
185 };
186 assert_eq!(plain.derived_features().count(), 0);
187 for job in [
188 JobConfig {
189 retry_on: vec![RetryOn::Error],
190 ..Default::default()
191 },
192 JobConfig {
193 backoff: Some(BackoffSpec::default()),
194 ..Default::default()
195 },
196 ] {
197 let schedule = ScheduleDefinition {
198 job: Some(job),
199 ..every(60)
200 };
201 assert_eq!(
202 schedule.derived_features().collect::<Vec<_>>(),
203 [features::JOBS_V2]
204 );
205 }
206 }
207
208 #[test]
209 fn names_are_lowercase_identifiers() {
210 for valid in ["order_retry", "a", "sync_2", &"s".repeat(64)] {
211 assert!(ScheduleDefinition::is_valid_name(valid), "{valid}");
212 }
213 for invalid in [
214 "",
215 "Order",
216 "order-retry",
217 "order.retry",
218 "a b",
219 &"s".repeat(65),
220 ] {
221 assert!(!ScheduleDefinition::is_valid_name(invalid), "{invalid}");
222 }
223 }
224}