1use areev_core::types::{Catchup, Trigger, TriggerKind};
24use croner::Cron;
25
26use crate::error::{Result, TriggerError};
27
28const SUPPORTED_TIMEZONE: &str = "UTC";
39
40pub fn validate(trigger: &Trigger) -> Result<()> {
45 if let Some(why) = trigger.incoherence() {
46 return Err(TriggerError::Malformed { what: why });
47 }
48 if let Err(e) = areev_core::error::Hash::from_hex(trigger.workflow_hash()) {
55 return Err(TriggerError::UnresolvedWorkflow {
56 hash: trigger.workflow.clone(),
57 why: format!(
58 "{e} — a workflow is named by its 64-hex content address, \
59 optionally prefixed `sha256:`"
60 ),
61 });
62 }
63 if let Some(spec) = trigger.context_query.as_deref() {
67 if let Err(why) = crate::context::parse_context_query(spec) {
68 return Err(TriggerError::Malformed {
69 what: format!("context_query: {why}"),
70 });
71 }
72 }
73 if trigger.kind == TriggerKind::Schedule {
74 let expr = trigger.cron.as_deref().unwrap_or_default();
75 parse_cron(expr)?;
76 if let Some(tz) = trigger.config_str(areev_core::types::config_keys::TIMEZONE) {
77 if !tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE) {
78 return Err(TriggerError::BadSchedule {
79 expr: expr.to_string(),
80 why: format!(
81 "timezone {tz:?} is not supported in this release — cron is evaluated \
82 in UTC. Declare the expression in UTC rather than having it fire at \
83 the wrong local hour across a DST boundary"
84 ),
85 });
86 }
87 }
88 }
89 if trigger.kind == TriggerKind::Composite {
90 if let Some(raw) = trigger.predicate.as_ref() {
99 let predicate = crate::predicate::from_value(raw)?;
100 for referenced in crate::predicate::referenced_members(&predicate) {
101 if !trigger.members.contains_key(&referenced) {
102 return Err(TriggerError::UnknownMember {
103 trigger: "being declared".into(),
104 member: referenced,
105 });
106 }
107 }
108 }
109 }
110 Ok(())
111}
112
113fn parse_cron(expr: &str) -> Result<Cron> {
114 Cron::new(expr)
115 .with_seconds_optional()
116 .parse()
117 .map_err(|e| TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() })
118}
119
120pub fn next_due_after(trigger: &Trigger, after_ms: i64) -> Result<Option<i64>> {
124 match trigger.kind {
125 TriggerKind::Once => {
126 let at = trigger.at_ms.ok_or_else(|| TriggerError::Malformed {
127 what: "a once trigger needs at_ms".into(),
128 })?;
129 Ok(if at > after_ms { Some(at) } else { None })
130 }
131 TriggerKind::Interval | TriggerKind::Polling => {
132 let secs = trigger.interval_secs.ok_or_else(|| TriggerError::Malformed {
133 what: format!("a {} trigger needs interval_secs", trigger.kind.as_str()),
134 })?;
135 Ok(Some(after_ms.saturating_add(i64::from(secs) * 1000)))
136 }
137 TriggerKind::Schedule => {
138 let expr = trigger.cron.as_deref().unwrap_or_default();
139 let cron = parse_cron(expr)?;
140 let after = chrono::DateTime::from_timestamp_millis(after_ms).ok_or_else(|| {
141 TriggerError::BadSchedule {
142 expr: expr.to_string(),
143 why: format!("{after_ms} is not a representable instant"),
144 }
145 })?;
146 let next = cron.find_next_occurrence(&after, false).map_err(|e| {
147 TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
148 })?;
149 Ok(Some(next.timestamp_millis()))
150 }
151 TriggerKind::Memory | TriggerKind::Webhook | TriggerKind::Manual | TriggerKind::Composite => {
153 Ok(None)
154 }
155 }
156}
157
158pub fn advance_after_firing(trigger: &Trigger, fired_at_ms: i64, now_ms: i64) -> Result<Option<i64>> {
165 let Some(mut next) = next_due_after(trigger, fired_at_ms)? else {
166 return Ok(None);
167 };
168 if next > now_ms {
169 return Ok(Some(next));
170 }
171 match trigger.catchup {
172 Catchup::All => Ok(Some(next)),
175 Catchup::Last | Catchup::None => {
179 const MAX_STEPS: u32 = 10_000;
183 let mut steps = 0;
184 while next <= now_ms && steps < MAX_STEPS {
185 match next_due_after(trigger, next)? {
186 Some(n) if n > next => next = n,
187 _ => break,
189 }
190 steps += 1;
191 }
192 if next <= now_ms {
193 next = next_due_after(trigger, now_ms)?.unwrap_or(next);
194 }
195 Ok(Some(next))
196 }
197 }
198}
199
200pub fn initial_due(trigger: &Trigger, now_ms: i64) -> Result<Option<i64>> {
216 Ok(match trigger.kind {
217 TriggerKind::Schedule => next_due_after(trigger, now_ms)?,
218 TriggerKind::Once => trigger.at_ms.filter(|at| *at > now_ms),
220 TriggerKind::Interval
221 | TriggerKind::Polling
222 | TriggerKind::Memory
223 | TriggerKind::Webhook
224 | TriggerKind::Manual
225 | TriggerKind::Composite => None,
226 })
227}
228
229pub fn is_due(trigger: &Trigger, state: &areev_store::TriggerState, now_ms: i64) -> Result<bool> {
235 if !state.permits_firing(now_ms) {
236 return Ok(false);
237 }
238 if state.next_due_at.is_some() {
239 return Ok(true); }
241 Ok(initial_due(trigger, now_ms)?.is_none())
242}
243
244pub fn exhausts_after(trigger: &Trigger, fired_at_ms: i64) -> bool {
251 trigger.kind == TriggerKind::Once
252 && next_due_after(trigger, fired_at_ms).ok().flatten().is_none()
253}
254
255pub fn backoff_until(trigger: &Trigger, consecutive_failures: u32, now_ms: i64) -> i64 {
262 const BASE_MS: i64 = 30_000;
263 const CAP_MS: i64 = 3_600_000;
264 let shift = consecutive_failures.saturating_sub(1).min(7);
265 let penalty = (BASE_MS << shift).min(CAP_MS);
266 let floor = trigger.interval_secs.map(|s| i64::from(s) * 1000).unwrap_or(0);
267 now_ms.saturating_add(penalty.max(floor))
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
275 const T0: i64 = 1_767_225_600_000;
277
278 fn interval(secs: u32) -> Trigger {
279 Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
280 }
281 fn schedule(expr: &str) -> Trigger {
282 Trigger::new(TriggerKind::Schedule, WF).cron(expr)
283 }
284
285 #[test]
286 fn interval_anchors_to_the_last_firing() {
287 let t = interval(120);
288 assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 120_000));
289 assert_eq!(next_due_after(&t, T0 + 5_000).unwrap(), Some(T0 + 125_000));
291 }
292
293 #[test]
294 fn cron_anchors_to_wall_clock_boundaries() {
295 let t = schedule("*/5 * * * *");
296 let first = next_due_after(&t, T0).unwrap().unwrap();
297 assert_eq!(first, T0 + 300_000, "next boundary from midnight");
298 let after_late = next_due_after(&t, T0 + 301_000).unwrap().unwrap();
300 assert_eq!(after_late, T0 + 600_000);
301 }
302
303 #[test]
304 fn a_five_field_weekday_expression_parses_with_posix_numbering() {
305 let t = schedule("0 9 * * 1-5");
309 let next = next_due_after(&t, T0).unwrap().unwrap();
310 let dt = chrono::DateTime::from_timestamp_millis(next).unwrap();
311 use chrono::{Datelike, Timelike};
312 assert_eq!(dt.hour(), 9);
313 assert_eq!(dt.minute(), 0);
314 let wd = dt.weekday().num_days_from_monday();
315 assert!(wd < 5, "must land Mon-Fri, got {:?}", dt.weekday());
316 }
317
318 #[test]
319 fn a_one_shot_fires_once_and_then_never() {
320 let t = Trigger::new(TriggerKind::Once, WF).at_ms(T0 + 1_000);
321 assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 1_000));
322 assert_eq!(next_due_after(&t, T0 + 1_000).unwrap(), None, "not due at its own instant again");
323 assert_eq!(next_due_after(&t, T0 + 9_999).unwrap(), None);
324 }
325
326 #[test]
327 fn push_and_gate_kinds_are_not_clocked() {
328 for kind in [TriggerKind::Webhook, TriggerKind::Manual, TriggerKind::Memory] {
329 let t = Trigger::new(kind, WF);
330 assert_eq!(next_due_after(&t, T0).unwrap(), None, "{kind:?} should not be clocked");
331 }
332 }
333
334 #[test]
335 fn catchup_last_collapses_a_weekend_of_missed_occurrences() {
336 let t = schedule("*/5 * * * *").catchup(Catchup::Last);
339 let three_days = 3 * 86_400_000;
340 let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
341 assert!(next > T0 + three_days, "must be scheduled ahead of now, not in the backlog");
342 assert!(next <= T0 + three_days + 300_000, "and at the very next boundary");
343 }
344
345 #[test]
346 fn catchup_all_leaves_the_backlog_in_place() {
347 let t = schedule("*/5 * * * *").catchup(Catchup::All);
348 let three_days = 3 * 86_400_000;
349 let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
350 assert_eq!(next, T0 + 300_000, "the stale occurrence stays due, to be replayed");
351 }
352
353 #[test]
354 fn collapsing_is_bounded_for_a_fast_interval_over_a_long_gap() {
355 let t = interval(1).catchup(Catchup::Last);
358 let a_year = 365 * 86_400_000i64;
359 let started = std::time::Instant::now();
360 let next = advance_after_firing(&t, T0, T0 + a_year).unwrap().unwrap();
361 assert!(next > T0 + a_year);
362 assert!(started.elapsed().as_millis() < 500, "the collapse walk must be bounded");
363 }
364
365 #[test]
366 fn a_firing_that_is_still_ahead_of_now_is_left_alone() {
367 let t = interval(300);
368 let next = advance_after_firing(&t, T0, T0 + 1_000).unwrap().unwrap();
369 assert_eq!(next, T0 + 300_000);
370 }
371
372 #[test]
373 fn backoff_grows_then_caps_and_never_polls_faster_than_the_interval() {
374 let t = interval(600); let first = backoff_until(&t, 1, T0) - T0;
376 let second = backoff_until(&t, 2, T0) - T0;
377 assert!(second >= first, "backoff must not shrink as failures accumulate");
378
379 assert!(first >= 600_000, "floor is the declared interval");
382
383 let far = backoff_until(&t, 99, T0) - T0;
384 assert!(far <= 3_600_000, "and it caps rather than growing without bound");
385 }
386
387 #[test]
388 fn an_unparseable_expression_is_refused_with_its_code() {
389 let t = schedule("not a cron expression");
390 let e = validate(&t).unwrap_err();
391 assert_eq!(e.code(), "TRG-E006");
392 assert!(e.to_string().starts_with("TRG-E006"));
393 }
394
395 #[test]
396 fn a_non_utc_timezone_is_refused_rather_than_mishandled() {
397 let t = schedule("0 9 * * *").config(serde_json::json!({
401 "int:timezone": "America/New_York"
402 }));
403 let e = validate(&t).unwrap_err();
404 assert_eq!(e.code(), "TRG-E006");
405 assert!(e.to_string().contains("UTC"), "the message should say what IS supported");
406
407 let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "utc" }));
409 assert!(validate(&ok).is_ok());
410 }
411
412 #[test]
413 fn validate_rejects_an_incoherent_declaration_before_the_schedule() {
414 let t = Trigger::new(TriggerKind::Interval, WF); let e = validate(&t).unwrap_err();
416 assert_eq!(e.code(), "TRG-E001");
417 }
418}