1use areev_core::types::{Catchup, Trigger, TriggerKind};
24use croner::Cron;
25
26use crate::error::{Result, TriggerError};
27
28const SUPPORTED_TIMEZONE: &str = "UTC";
30
31#[cfg(feature = "tz")]
50fn resolve_zone(tz: &str) -> Option<chrono_tz::Tz> {
51 tz.parse::<chrono_tz::Tz>().ok()
52}
53
54pub fn validate(trigger: &Trigger) -> Result<()> {
59 if let Some(why) = trigger.incoherence() {
60 return Err(TriggerError::Malformed { what: why });
61 }
62 if let Err(e) = areev_core::error::Hash::from_hex(trigger.workflow_hash()) {
69 return Err(TriggerError::UnresolvedWorkflow {
70 hash: trigger.workflow.clone(),
71 why: format!(
72 "{e} — a workflow is named by its 64-hex content address, \
73 optionally prefixed `sha256:`"
74 ),
75 });
76 }
77 if let Some(spec) = trigger.context_query.as_deref() {
81 if let Err(why) = crate::context::parse_context_query(spec) {
82 return Err(TriggerError::Malformed {
83 what: format!("context_query: {why}"),
84 });
85 }
86 }
87 if trigger.kind == TriggerKind::Schedule {
88 let expr = trigger.cron.as_deref().unwrap_or_default();
89 parse_cron(expr)?;
90 if let Some(tz) = trigger.config_str(areev_core::types::config_keys::TIMEZONE) {
91 check_timezone(expr, tz)?;
92 }
93 }
94 if trigger.kind == TriggerKind::Composite {
95 if let Some(raw) = trigger.predicate.as_ref() {
104 let predicate = crate::predicate::from_value(raw)?;
105 for referenced in crate::predicate::referenced_members(&predicate) {
106 if !trigger.members.contains_key(&referenced) {
107 return Err(TriggerError::UnknownMember {
108 trigger: "being declared".into(),
109 member: referenced,
110 });
111 }
112 }
113 }
114 }
115 Ok(())
116}
117
118fn check_timezone(expr: &str, tz: &str) -> Result<()> {
126 if tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE) {
127 return Ok(());
128 }
129 #[cfg(feature = "tz")]
130 {
131 if resolve_zone(tz).is_some() {
132 return Ok(());
133 }
134 Err(TriggerError::BadSchedule {
135 expr: expr.to_string(),
136 why: format!(
137 "timezone {tz:?} is not an IANA zone name this build's zone data knows \
138 (e.g. \"America/New_York\", \"Asia/Kolkata\", \"UTC\")"
139 ),
140 })
141 }
142 #[cfg(not(feature = "tz"))]
143 {
144 Err(TriggerError::BadSchedule {
145 expr: expr.to_string(),
146 why: format!(
147 "timezone {tz:?} needs zone data this build does not carry — rebuild \
148 areev-trigger with the `tz` feature, or declare the expression in UTC"
149 ),
150 })
151 }
152}
153
154pub fn declared_zone(trigger: &Trigger) -> Option<String> {
159 if trigger.kind != TriggerKind::Schedule {
160 return None;
161 }
162 trigger_zone(trigger)
163}
164
165#[cfg(feature = "tz")]
172pub fn render_local(trigger: &Trigger, at_ms: i64) -> Option<String> {
173 let tz = declared_zone(trigger)?;
174 let zone = resolve_zone(&tz)?;
175 let utc = chrono::DateTime::from_timestamp_millis(at_ms)?;
176 Some(format!(
177 "{} {}",
178 utc.with_timezone(&zone).format("%Y-%m-%dT%H:%M:%S%:z"),
179 tz
180 ))
181}
182
183#[cfg(not(feature = "tz"))]
184pub fn render_local(_trigger: &Trigger, _at_ms: i64) -> Option<String> {
185 None
186}
187
188fn trigger_zone(trigger: &Trigger) -> Option<String> {
190 trigger
191 .config_str(areev_core::types::config_keys::TIMEZONE)
192 .filter(|tz| !tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE))
193 .map(str::to_string)
194}
195
196fn parse_cron(expr: &str) -> Result<Cron> {
197 Cron::new(expr)
198 .with_seconds_optional()
199 .parse()
200 .map_err(|e| TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() })
201}
202
203pub fn next_due_after(trigger: &Trigger, after_ms: i64) -> Result<Option<i64>> {
207 match trigger.kind {
208 TriggerKind::Once => {
209 let at = trigger.at_ms.ok_or_else(|| TriggerError::Malformed {
210 what: "a once trigger needs at_ms".into(),
211 })?;
212 Ok(if at > after_ms { Some(at) } else { None })
213 }
214 TriggerKind::Interval | TriggerKind::Polling => {
215 let secs = trigger.interval_secs.ok_or_else(|| TriggerError::Malformed {
216 what: format!("a {} trigger needs interval_secs", trigger.kind.as_str()),
217 })?;
218 Ok(Some(after_ms.saturating_add(i64::from(secs) * 1000)))
219 }
220 TriggerKind::Schedule => {
221 let expr = trigger.cron.as_deref().unwrap_or_default();
222 let cron = parse_cron(expr)?;
223 let after = chrono::DateTime::from_timestamp_millis(after_ms).ok_or_else(|| {
224 TriggerError::BadSchedule {
225 expr: expr.to_string(),
226 why: format!("{after_ms} is not a representable instant"),
227 }
228 })?;
229 match trigger_zone(trigger) {
230 None => {
231 let next = cron.find_next_occurrence(&after, false).map_err(|e| {
232 TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
233 })?;
234 Ok(Some(next.timestamp_millis()))
235 }
236 Some(tz) => next_local_occurrence(expr, &cron, &tz, after_ms, after),
237 }
238 }
239 TriggerKind::Memory | TriggerKind::Webhook | TriggerKind::Manual | TriggerKind::Composite => {
241 Ok(None)
242 }
243 }
244}
245
246#[cfg(feature = "tz")]
257fn next_local_occurrence(
258 expr: &str,
259 cron: &Cron,
260 tz: &str,
261 after_ms: i64,
262 after: chrono::DateTime<chrono::Utc>,
263) -> Result<Option<i64>> {
264 use chrono::TimeZone;
265 let zone = resolve_zone(tz).ok_or_else(|| TriggerError::BadSchedule {
266 expr: expr.to_string(),
267 why: format!("timezone {tz:?} is not a zone name this build knows"),
268 })?;
269 let mut cursor = after.with_timezone(&zone);
270 for _ in 0..8 {
274 let next = cron.find_next_occurrence(&cursor, false).map_err(|e| {
275 TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
276 })?;
277 let ms = next.timestamp_millis();
278 if ms > after_ms {
279 return Ok(Some(ms));
280 }
281 cursor = zone.from_utc_datetime(
284 &chrono::DateTime::from_timestamp_millis(ms + 1_000)
285 .ok_or_else(|| TriggerError::BadSchedule {
286 expr: expr.to_string(),
287 why: "instant out of range while resolving a DST fold".into(),
288 })?
289 .naive_utc(),
290 );
291 }
292 Err(TriggerError::BadSchedule {
293 expr: expr.to_string(),
294 why: format!(
295 "no occurrence strictly after {after_ms} in {tz} within the DST search bound"
296 ),
297 })
298}
299
300#[cfg(not(feature = "tz"))]
305fn next_local_occurrence(
306 expr: &str,
307 _cron: &Cron,
308 tz: &str,
309 _after_ms: i64,
310 _after: chrono::DateTime<chrono::Utc>,
311) -> Result<Option<i64>> {
312 Err(TriggerError::BadSchedule {
313 expr: expr.to_string(),
314 why: format!(
315 "timezone {tz:?} needs zone data this build does not carry — rebuild \
316 areev-trigger with the `tz` feature, or declare the expression in UTC"
317 ),
318 })
319}
320
321pub fn advance_after_firing(trigger: &Trigger, fired_at_ms: i64, now_ms: i64) -> Result<Option<i64>> {
328 let Some(mut next) = next_due_after(trigger, fired_at_ms)? else {
329 return Ok(None);
330 };
331 if next > now_ms {
332 return Ok(Some(next));
333 }
334 match trigger.catchup {
335 Catchup::All => Ok(Some(next)),
338 Catchup::Last | Catchup::None => {
342 const MAX_STEPS: u32 = 10_000;
346 let mut steps = 0;
347 while next <= now_ms && steps < MAX_STEPS {
348 match next_due_after(trigger, next)? {
349 Some(n) if n > next => next = n,
350 _ => break,
352 }
353 steps += 1;
354 }
355 if next <= now_ms {
356 next = next_due_after(trigger, now_ms)?.unwrap_or(next);
357 }
358 Ok(Some(next))
359 }
360 }
361}
362
363pub fn initial_due(trigger: &Trigger, now_ms: i64) -> Result<Option<i64>> {
379 Ok(match trigger.kind {
380 TriggerKind::Schedule => next_due_after(trigger, now_ms)?,
381 TriggerKind::Once => trigger.at_ms.filter(|at| *at > now_ms),
383 TriggerKind::Interval
384 | TriggerKind::Polling
385 | TriggerKind::Memory
386 | TriggerKind::Webhook
387 | TriggerKind::Manual
388 | TriggerKind::Composite => None,
389 })
390}
391
392pub fn is_due(trigger: &Trigger, state: &areev_store::TriggerState, now_ms: i64) -> Result<bool> {
398 if !state.permits_firing(now_ms) {
399 return Ok(false);
400 }
401 if state.next_due_at.is_some() {
402 return Ok(true); }
404 Ok(initial_due(trigger, now_ms)?.is_none())
405}
406
407pub fn exhausts_after(trigger: &Trigger, fired_at_ms: i64) -> bool {
414 trigger.kind == TriggerKind::Once
415 && next_due_after(trigger, fired_at_ms).ok().flatten().is_none()
416}
417
418pub fn backoff_until(trigger: &Trigger, consecutive_failures: u32, now_ms: i64) -> i64 {
425 const BASE_MS: i64 = 30_000;
426 const CAP_MS: i64 = 3_600_000;
427 let shift = consecutive_failures.saturating_sub(1).min(7);
428 let penalty = (BASE_MS << shift).min(CAP_MS);
429 let floor = trigger.interval_secs.map(|s| i64::from(s) * 1000).unwrap_or(0);
430 now_ms.saturating_add(penalty.max(floor))
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
438 const T0: i64 = 1_767_225_600_000;
440
441 fn interval(secs: u32) -> Trigger {
442 Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
443 }
444 fn schedule(expr: &str) -> Trigger {
445 Trigger::new(TriggerKind::Schedule, WF).cron(expr)
446 }
447
448 #[test]
449 fn interval_anchors_to_the_last_firing() {
450 let t = interval(120);
451 assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 120_000));
452 assert_eq!(next_due_after(&t, T0 + 5_000).unwrap(), Some(T0 + 125_000));
454 }
455
456 #[test]
457 fn cron_anchors_to_wall_clock_boundaries() {
458 let t = schedule("*/5 * * * *");
459 let first = next_due_after(&t, T0).unwrap().unwrap();
460 assert_eq!(first, T0 + 300_000, "next boundary from midnight");
461 let after_late = next_due_after(&t, T0 + 301_000).unwrap().unwrap();
463 assert_eq!(after_late, T0 + 600_000);
464 }
465
466 #[test]
467 fn a_five_field_weekday_expression_parses_with_posix_numbering() {
468 let t = schedule("0 9 * * 1-5");
472 let next = next_due_after(&t, T0).unwrap().unwrap();
473 let dt = chrono::DateTime::from_timestamp_millis(next).unwrap();
474 use chrono::{Datelike, Timelike};
475 assert_eq!(dt.hour(), 9);
476 assert_eq!(dt.minute(), 0);
477 let wd = dt.weekday().num_days_from_monday();
478 assert!(wd < 5, "must land Mon-Fri, got {:?}", dt.weekday());
479 }
480
481 #[test]
482 fn a_one_shot_fires_once_and_then_never() {
483 let t = Trigger::new(TriggerKind::Once, WF).at_ms(T0 + 1_000);
484 assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 1_000));
485 assert_eq!(next_due_after(&t, T0 + 1_000).unwrap(), None, "not due at its own instant again");
486 assert_eq!(next_due_after(&t, T0 + 9_999).unwrap(), None);
487 }
488
489 #[test]
490 fn push_and_gate_kinds_are_not_clocked() {
491 for kind in [TriggerKind::Webhook, TriggerKind::Manual, TriggerKind::Memory] {
492 let t = Trigger::new(kind, WF);
493 assert_eq!(next_due_after(&t, T0).unwrap(), None, "{kind:?} should not be clocked");
494 }
495 }
496
497 #[test]
498 fn catchup_last_collapses_a_weekend_of_missed_occurrences() {
499 let t = schedule("*/5 * * * *").catchup(Catchup::Last);
502 let three_days = 3 * 86_400_000;
503 let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
504 assert!(next > T0 + three_days, "must be scheduled ahead of now, not in the backlog");
505 assert!(next <= T0 + three_days + 300_000, "and at the very next boundary");
506 }
507
508 #[test]
509 fn catchup_all_leaves_the_backlog_in_place() {
510 let t = schedule("*/5 * * * *").catchup(Catchup::All);
511 let three_days = 3 * 86_400_000;
512 let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
513 assert_eq!(next, T0 + 300_000, "the stale occurrence stays due, to be replayed");
514 }
515
516 #[test]
517 fn collapsing_is_bounded_for_a_fast_interval_over_a_long_gap() {
518 let t = interval(1).catchup(Catchup::Last);
521 let a_year = 365 * 86_400_000i64;
522 let started = std::time::Instant::now();
523 let next = advance_after_firing(&t, T0, T0 + a_year).unwrap().unwrap();
524 assert!(next > T0 + a_year);
525 assert!(started.elapsed().as_millis() < 500, "the collapse walk must be bounded");
526 }
527
528 #[test]
529 fn a_firing_that_is_still_ahead_of_now_is_left_alone() {
530 let t = interval(300);
531 let next = advance_after_firing(&t, T0, T0 + 1_000).unwrap().unwrap();
532 assert_eq!(next, T0 + 300_000);
533 }
534
535 #[test]
536 fn backoff_grows_then_caps_and_never_polls_faster_than_the_interval() {
537 let t = interval(600); let first = backoff_until(&t, 1, T0) - T0;
539 let second = backoff_until(&t, 2, T0) - T0;
540 assert!(second >= first, "backoff must not shrink as failures accumulate");
541
542 assert!(first >= 600_000, "floor is the declared interval");
545
546 let far = backoff_until(&t, 99, T0) - T0;
547 assert!(far <= 3_600_000, "and it caps rather than growing without bound");
548 }
549
550 #[test]
551 fn an_unparseable_expression_is_refused_with_its_code() {
552 let t = schedule("not a cron expression");
553 let e = validate(&t).unwrap_err();
554 assert_eq!(e.code(), "TRG-E006");
555 assert!(e.to_string().starts_with("TRG-E006"));
556 }
557
558 #[test]
559 fn utc_is_accepted_however_it_is_spelled() {
560 let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "utc" }));
561 assert!(validate(&ok).is_ok());
562 let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "UTC" }));
563 assert!(validate(&ok).is_ok());
564 }
565
566 #[test]
567 fn an_unknown_zone_name_is_refused_whatever_the_build() {
568 let t = schedule("0 9 * * *")
571 .config(serde_json::json!({ "int:timezone": "Mars/Olympus" }));
572 let e = validate(&t).unwrap_err();
573 assert_eq!(e.code(), "TRG-E006");
574 }
575
576 #[cfg(not(feature = "tz"))]
577 #[test]
578 fn a_build_without_zone_data_refuses_and_names_the_feature() {
579 let t = schedule("0 9 * * *")
580 .config(serde_json::json!({ "int:timezone": "America/New_York" }));
581 let e = validate(&t).unwrap_err();
582 assert_eq!(e.code(), "TRG-E006");
583 assert!(e.to_string().contains("`tz` feature"), "{e}");
584 }
585
586 #[cfg(feature = "tz")]
587 mod tz_tests {
588 use super::*;
589
590 fn zoned(expr: &str, tz: &str) -> Trigger {
591 schedule(expr).config(serde_json::json!({ "int:timezone": tz }))
592 }
593
594 fn ms(iso: &str) -> i64 {
595 areev_core::time::iso8601_to_ms(iso).unwrap()
596 }
597
598 #[test]
599 fn a_declared_zone_validates() {
600 assert!(validate(&zoned("0 8 * * 1", "America/New_York")).is_ok());
601 assert!(validate(&zoned("0 9 * * *", "Asia/Kolkata")).is_ok());
602 }
603
604 #[test]
605 fn a_weekly_local_time_tracks_the_offset_across_the_dst_boundary() {
606 let t = zoned("0 8 * * 1", "America/New_York");
610 let first = next_due_after(&t, ms("2026-10-19T12:00:01Z")).unwrap().unwrap();
611 assert_eq!(first, ms("2026-10-26T12:00:00Z"), "still EDT");
612 let second = next_due_after(&t, first).unwrap().unwrap();
613 assert_eq!(second, ms("2026-11-02T13:00:00Z"), "EST after the change");
614 }
615
616 #[test]
617 fn a_time_skipped_by_spring_forward_fires_once_at_the_next_valid_minute() {
618 let t = zoned("30 2 * * *", "America/New_York");
624 let gap_day = next_due_after(&t, ms("2026-03-07T12:00:00Z")).unwrap().unwrap();
625 assert_eq!(
626 gap_day,
627 ms("2026-03-08T07:00:00Z"),
628 "03:00 EDT — the first valid instant after the gap"
629 );
630 let next = next_due_after(&t, gap_day).unwrap().unwrap();
633 assert_eq!(next, ms("2026-03-09T06:30:00Z"));
634 }
635
636 #[test]
637 fn an_ordinary_day_fires_at_the_declared_local_time() {
638 let t = zoned("30 2 * * *", "America/New_York");
640 let fired = next_due_after(&t, ms("2026-03-06T12:00:00Z")).unwrap().unwrap();
641 assert_eq!(fired, ms("2026-03-07T07:30:00Z"));
642 }
643
644 #[test]
645 fn a_repeated_time_fires_once_at_the_earlier_occurrence() {
646 let t = zoned("30 1 * * *", "America/New_York");
649 let first = next_due_after(&t, ms("2026-11-01T00:00:00Z")).unwrap().unwrap();
650 assert_eq!(first, ms("2026-11-01T05:30:00Z"), "the earlier one");
651 let next = next_due_after(&t, first).unwrap().unwrap();
652 assert_eq!(
653 next,
654 ms("2026-11-02T06:30:00Z"),
655 "the next DAY, not the second pass of the repeated hour"
656 );
657 }
658
659 #[test]
660 fn every_answer_is_strictly_after_the_question_across_the_fold() {
661 let t = zoned("30 1 * * *", "America/New_York");
666 let start = ms("2026-11-01T05:00:00Z");
667 let end = ms("2026-11-01T07:00:00Z");
668 let mut at = start;
669 while at < end {
670 let got = next_due_after(&t, at).unwrap().unwrap();
671 assert!(got > at, "answer {got} must be strictly after {at}");
672 at += 60_000;
673 }
674 }
675
676 #[test]
677 fn catchup_last_across_a_dst_boundary_collapses_to_one_firing() {
678 let mut t = zoned("30 1 * * *", "America/New_York");
679 t.catchup = Catchup::Last;
680 let fired = ms("2026-10-30T05:30:00Z");
681 let now = ms("2026-11-03T12:00:00Z");
682 let next = advance_after_firing(&t, fired, now).unwrap().unwrap();
683 assert!(next > now, "one firing ahead, not a backlog: {next}");
684 }
685
686 #[test]
687 fn an_absent_or_utc_zone_is_unchanged() {
688 let utc = schedule("0 9 * * *");
689 let zoned_utc = zoned("0 9 * * *", "UTC");
690 let at = ms("2026-03-08T00:00:00Z");
691 assert_eq!(
692 next_due_after(&utc, at).unwrap(),
693 next_due_after(&zoned_utc, at).unwrap()
694 );
695 }
696 }
697
698 #[test]
699 fn validate_rejects_an_incoherent_declaration_before_the_schedule() {
700 let t = Trigger::new(TriggerKind::Interval, WF); let e = validate(&t).unwrap_err();
702 assert_eq!(e.code(), "TRG-E001");
703 }
704}