1use crate::engine::{Engine, EngineError, Result};
26use crate::store::{FireRecord, ScheduleRow};
27use jiff::civil::{Date, DateTime};
28use jiff::tz::TimeZone;
29use jiff::Timestamp;
30use serde::{Deserialize, Serialize};
31use serde_json::{json, Value};
32use sha2::{Digest, Sha256};
33
34pub const CLAIM_MS: u64 = 30_000;
36pub const RETRY_MS: u64 = 5_000;
38pub const MISSED_AFTER_MS: u64 = 60_000;
40pub const POLL_MS: u64 = 1_000;
42const MAX_MISSED: u64 = 100_000;
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Overlap {
50 #[default]
52 Skip,
53 Allow,
55 CancelPrevious,
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Catchup {
63 #[default]
65 SkipMissed,
66 RunOnce,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct ScheduleSpec {
75 pub workflow: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub version: Option<String>,
79 #[serde(default)]
80 pub input: Value,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub cron: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub interval: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub timezone: Option<String>,
91 #[serde(default)]
92 pub overlap: Overlap,
93 #[serde(default)]
94 pub catchup: Catchup,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub start_at: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub end_at: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub jitter: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub description: Option<String>,
107}
108
109fn invalid(msg: impl Into<String>) -> EngineError {
110 EngineError::new(400, "INVALID_SCHEDULE", msg)
111}
112
113#[derive(Debug, Clone)]
115pub struct Plan {
116 timing: Timing,
117 start_ms: Option<u64>,
118 end_ms: Option<u64>,
119 jitter_ms: u64,
120}
121
122#[derive(Debug, Clone)]
123enum Timing {
124 Cron(Box<Cron>, TimeZone),
125 Interval { every_ms: u64 },
126}
127
128impl ScheduleSpec {
129 pub fn plan(&self) -> Result<Plan> {
131 let timing = match (&self.cron, &self.interval) {
132 (Some(_), Some(_)) => {
133 return Err(invalid("give either `cron` or `interval`, not both"))
134 }
135 (None, None) => return Err(invalid("a schedule needs `cron` or `interval`")),
136 (Some(c), None) => {
137 let cron = Cron::parse(c).map_err(|e| invalid(format!("cron `{c}`: {e}")))?;
138 let tz = match &self.timezone {
139 None => TimeZone::UTC,
140 Some(name) => TimeZone::get(name)
141 .map_err(|_| invalid(format!("unknown time zone `{name}`")))?,
142 };
143 Timing::Cron(Box::new(cron), tz)
144 }
145 (None, Some(i)) => {
146 if self.timezone.is_some() {
147 return Err(invalid(
148 "`timezone` applies to `cron` only; intervals are absolute",
149 ));
150 }
151 let every_ms =
152 parse_duration(i).map_err(|e| invalid(format!("interval `{i}`: {e}")))?;
153 if every_ms < 1_000 {
154 return Err(invalid("the interval must be at least 1s"));
155 }
156 Timing::Interval { every_ms }
157 }
158 };
159 let start_ms = self
160 .start_at
161 .as_deref()
162 .map(|t| parse_time(t).map_err(|e| invalid(format!("start_at: {e}"))))
163 .transpose()?;
164 let end_ms = self
165 .end_at
166 .as_deref()
167 .map(|t| parse_time(t).map_err(|e| invalid(format!("end_at: {e}"))))
168 .transpose()?;
169 if let (Some(s), Some(e)) = (start_ms, end_ms) {
170 if e <= s {
171 return Err(invalid("end_at must be after start_at"));
172 }
173 }
174 let jitter_ms = match &self.jitter {
175 None => 0,
176 Some(j) => parse_duration(j).map_err(|e| invalid(format!("jitter `{j}`: {e}")))?,
177 };
178 if jitter_ms > 24 * 3_600_000 {
179 return Err(invalid("jitter must be at most 24h"));
180 }
181 Ok(Plan {
182 timing,
183 start_ms,
184 end_ms,
185 jitter_ms,
186 })
187 }
188}
189
190impl Plan {
191 pub fn next_after(&self, t: u64) -> Option<u64> {
193 let from = match self.start_ms {
194 Some(s) if s > 0 && t < s => s - 1,
195 _ => t,
196 };
197 let next = match &self.timing {
198 Timing::Interval { every_ms } => {
199 let anchor = self.start_ms.unwrap_or(0);
200 if from < anchor {
201 anchor
202 } else {
203 anchor + ((from - anchor) / every_ms + 1) * every_ms
204 }
205 }
206 Timing::Cron(cron, tz) => cron.next_after(from, tz)?,
207 };
208 match self.end_ms {
209 Some(end) if next > end => None,
210 _ => Some(next),
211 }
212 }
213
214 pub fn upcoming(&self, t: u64, n: usize) -> Vec<u64> {
216 let mut out = vec![];
217 let mut at = t;
218 while out.len() < n {
219 match self.next_after(at) {
220 Some(o) => {
221 out.push(o);
222 at = o;
223 }
224 None => break,
225 }
226 }
227 out
228 }
229
230 pub fn jitter_for(&self, schedule_id: &str, occurrence: u64) -> u64 {
233 if self.jitter_ms == 0 {
234 return 0;
235 }
236 let digest = Sha256::digest(format!("{schedule_id}@{occurrence}").as_bytes());
237 let mut n = [0u8; 8];
238 n.copy_from_slice(&digest[..8]);
239 u64::from_be_bytes(n) % self.jitter_ms
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct Cron {
250 minutes: u64,
251 hours: u32,
252 days: u32,
253 months: u16,
254 weekdays: u8,
255 days_star: bool,
259 weekdays_star: bool,
260}
261
262const MONTHS: &[&str] = &[
263 "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
264];
265const WEEKDAYS: &[&str] = &["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
266
267fn field(
268 text: &str,
269 min: u32,
270 max: u32,
271 names: &[&str],
272 what: &str,
273) -> std::result::Result<u64, String> {
274 let value = |s: &str| -> std::result::Result<u32, String> {
275 if let Some(i) = names.iter().position(|n| n.eq_ignore_ascii_case(s)) {
276 return Ok(i as u32 + min);
277 }
278 s.parse::<u32>()
279 .map_err(|_| format!("`{s}` is not a valid {what}"))
280 };
281 let mut mask = 0u64;
282 for part in text.split(',') {
283 let (range, step) = match part.split_once('/') {
284 Some((r, s)) => {
285 let step: u32 = s
286 .parse()
287 .map_err(|_| format!("`{s}` is not a valid step in the {what} field"))?;
288 if step == 0 {
289 return Err(format!("the step in the {what} field must be at least 1"));
290 }
291 (r, Some(step))
292 }
293 None => (part, None),
294 };
295 let (lo, hi) = if range == "*" {
296 (min, max)
297 } else if let Some((a, b)) = range.split_once('-') {
298 (value(a)?, value(b)?)
299 } else {
300 let v = value(range)?;
301 (v, if step.is_some() { max } else { v })
303 };
304 let hi = if what == "day of week" && hi == 7 {
305 7
306 } else {
307 hi
308 };
309 let top = if what == "day of week" { 7 } else { max };
310 if lo < min || hi > top || lo > hi {
311 return Err(format!(
312 "`{range}` is out of range for the {what} field ({min}-{max})"
313 ));
314 }
315 let mut v = lo;
316 while v <= hi {
317 let bit = if what == "day of week" && v == 7 {
318 0
319 } else {
320 v
321 };
322 mask |= 1 << bit;
323 v += step.unwrap_or(1);
324 }
325 }
326 Ok(mask)
327}
328
329impl Cron {
330 pub fn parse(text: &str) -> std::result::Result<Cron, String> {
331 let text = text.trim();
332 let expanded = match text.to_ascii_lowercase().as_str() {
333 "@yearly" | "@annually" => "0 0 1 1 *".to_string(),
334 "@monthly" => "0 0 1 * *".to_string(),
335 "@weekly" => "0 0 * * 0".to_string(),
336 "@daily" | "@midnight" => "0 0 * * *".to_string(),
337 "@hourly" => "0 * * * *".to_string(),
338 m if m.starts_with('@') => return Err(format!("unknown macro `{text}`")),
339 _ => text.to_string(),
340 };
341 let parts: Vec<&str> = expanded.split_whitespace().collect();
342 if parts.len() != 5 {
343 return Err(format!(
344 "expected 5 fields (minute hour day-of-month month day-of-week), found {}",
345 parts.len()
346 ));
347 }
348 Ok(Cron {
349 minutes: field(parts[0], 0, 59, &[], "minute")?,
350 hours: field(parts[1], 0, 23, &[], "hour")? as u32,
351 days: field(parts[2], 1, 31, &[], "day of month")? as u32,
352 months: field(parts[3], 1, 12, MONTHS, "month")? as u16,
353 weekdays: field(parts[4], 0, 6, WEEKDAYS, "day of week")? as u8,
354 days_star: parts[2].starts_with('*'),
355 weekdays_star: parts[4].starts_with('*'),
356 })
357 }
358
359 fn day_matches(&self, d: Date) -> bool {
360 let dom = self.days & (1 << d.day() as u32) != 0;
361 let dow = self.weekdays & (1 << d.weekday().to_sunday_zero_offset() as u8) != 0;
362 if self.days_star || self.weekdays_star {
363 dom && dow
364 } else {
365 dom || dow
366 }
367 }
368
369 pub fn next_after(&self, t: u64, tz: &TimeZone) -> Option<u64> {
374 let ts = Timestamp::from_millisecond(t as i64).ok()?;
375 let local = ts.to_zoned(tz.clone()).datetime();
376 let start = DateTime::from_parts(
378 local.date(),
379 jiff::civil::time(local.hour(), local.minute(), 0, 0),
380 )
381 .checked_add(jiff::Span::new().minutes(1))
382 .ok()?;
383 let mut day = start.date();
384 for _ in 0..(8 * 366 + 2) {
386 if self.months & (1 << day.month() as u16) == 0 {
387 day = day
388 .first_of_month()
389 .checked_add(jiff::Span::new().months(1))
390 .ok()?;
391 continue;
392 }
393 if self.day_matches(day) {
394 for h in 0..24i8 {
395 if self.hours & (1 << h) == 0 {
396 continue;
397 }
398 if day == start.date() && h < start.hour() {
399 continue;
400 }
401 for m in 0..60i8 {
402 if self.minutes & (1 << m) == 0 {
403 continue;
404 }
405 if day == start.date() && h == start.hour() && m < start.minute() {
406 continue;
407 }
408 let dt = DateTime::from_parts(day, jiff::civil::time(h, m, 0, 0));
409 let at = tz.to_ambiguous_timestamp(dt).compatible().ok()?;
410 let ms = at.as_millisecond();
411 if ms > t as i64 {
412 return Some(ms as u64);
413 }
414 }
415 }
416 }
417 day = day.tomorrow().ok()?;
418 }
419 None
420 }
421}
422
423pub fn parse_duration(text: &str) -> std::result::Result<u64, String> {
427 let text = text.trim();
428 if text.is_empty() {
429 return Err("empty duration".into());
430 }
431 let mut total: u64 = 0;
432 let mut rest = text;
433 while !rest.is_empty() {
434 let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
435 if digits == 0 {
436 return Err(format!("expected a number in `{text}`"));
437 }
438 let n: u64 = rest[..digits]
439 .parse()
440 .map_err(|_| format!("number too large in `{text}`"))?;
441 rest = &rest[digits..];
442 let unit_len = rest.chars().take_while(|c| c.is_ascii_alphabetic()).count();
443 let unit = &rest[..unit_len];
444 rest = &rest[unit_len..];
445 let factor = match unit {
446 "ms" => 1,
447 "s" => 1_000,
448 "m" => 60_000,
449 "h" => 3_600_000,
450 "d" => 86_400_000,
451 "" => return Err(format!("`{text}` is missing a unit (ms, s, m, h or d)")),
452 other => return Err(format!("unknown unit `{other}` (use ms, s, m, h or d)")),
453 };
454 total = n
455 .checked_mul(factor)
456 .and_then(|v| total.checked_add(v))
457 .ok_or_else(|| format!("`{text}` is too long"))?;
458 }
459 Ok(total)
460}
461
462pub fn parse_time(text: &str) -> std::result::Result<u64, String> {
464 let ts: Timestamp = text
465 .parse()
466 .map_err(|e| format!("`{text}` is not an RFC 3339 timestamp: {e}"))?;
467 u64::try_from(ts.as_millisecond()).map_err(|_| format!("`{text}` is before 1970"))
468}
469
470pub fn format_time(ms: u64) -> String {
472 Timestamp::from_millisecond(ms as i64)
473 .map(|t| t.to_string())
474 .unwrap_or_default()
475}
476
477pub fn fire_execution_id(schedule_id: &str, occurrence: u64) -> String {
479 format!("{schedule_id}:{}", format_time(occurrence))
480}
481
482pub fn valid_schedule_id(id: &str) -> bool {
484 !id.is_empty()
485 && id.len() <= 100
486 && id
487 .chars()
488 .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
489}
490
491pub fn view(row: &ScheduleRow) -> Value {
494 let spec: Value = serde_json::from_str(&row.spec).unwrap_or(Value::Null);
495 let plan = serde_json::from_value::<ScheduleSpec>(spec.clone())
496 .ok()
497 .and_then(|s| s.plan().ok());
498 let next = if row.paused {
499 vec![]
500 } else {
501 match (row.next_fire_ms, &plan) {
502 (Some(n), Some(p)) => {
503 let mut v = vec![n];
504 v.extend(p.upcoming(n, 4));
505 v
506 }
507 _ => vec![],
508 }
509 };
510 let mut out = json!({ "schedule_id": row.schedule_id });
511 if let (Some(o), Value::Object(s)) = (out.as_object_mut(), spec) {
512 o.extend(s);
513 }
514 let opt_time = |v: Option<u64>| v.map(format_time);
515 let o = out.as_object_mut().expect("object");
516 o.insert("paused".into(), json!(row.paused));
517 o.insert(
518 "next_fire_at".into(),
519 json!(if row.paused {
520 None
521 } else {
522 row.due_ms.map(format_time)
523 }),
524 );
525 o.insert(
526 "next_fire_times".into(),
527 json!(next.iter().map(|t| format_time(*t)).collect::<Vec<_>>()),
528 );
529 o.insert("last_fire_at".into(), json!(opt_time(row.last_fire_ms)));
530 o.insert("last_execution_id".into(), json!(row.last_execution_id));
531 o.insert("fired".into(), json!(row.fired));
532 o.insert("skipped".into(), json!(row.skipped));
533 o.insert("last_error".into(), json!(row.last_error));
534 o.insert("created_at".into(), json!(format_time(row.created_at_ms)));
535 o.insert("updated_at".into(), json!(format_time(row.updated_at_ms)));
536 out
537}
538
539#[derive(Debug, Clone, Deserialize)]
543pub struct CreateSchedule {
544 pub schedule_id: String,
545 #[serde(default)]
546 pub paused: bool,
547 #[serde(flatten)]
548 pub spec: Value,
549}
550
551#[derive(Debug, Clone, Serialize)]
553pub struct Triggered {
554 pub schedule_id: String,
555 pub started: bool,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 pub execution_id: Option<String>,
558 #[serde(skip_serializing_if = "Option::is_none")]
560 pub skipped: Option<String>,
561}
562
563fn not_found(id: &str) -> EngineError {
564 EngineError::new(
565 404,
566 "SCHEDULE_NOT_FOUND",
567 format!("schedule `{id}` not found"),
568 )
569}
570
571fn terminal(status: &str) -> bool {
572 !matches!(status, "RUNNING" | "COMPENSATING")
573}
574
575impl Engine {
576 fn parse_spec(&self, spec: Value) -> Result<(ScheduleSpec, Plan)> {
577 let mut spec: ScheduleSpec =
578 serde_json::from_value(spec).map_err(|e| invalid(format!("invalid schedule: {e}")))?;
579 let plan = spec.plan()?;
580 let def = self
581 .definition(&spec.workflow, spec.version.as_deref())
582 .ok_or_else(|| {
583 EngineError::new(
584 404,
585 "WORKFLOW_NOT_FOUND",
586 match &spec.version {
587 Some(v) => format!("workflow {}@{v} is not registered", spec.workflow),
588 None => format!("workflow {} is not registered", spec.workflow),
589 },
590 )
591 })?;
592 if spec.input.is_null() {
593 spec.input = json!({});
594 }
595 let mut checked = spec.input.clone();
596 def.input.apply_defaults(&mut checked);
597 def.input.validate(&checked, true).map_err(|e| {
598 EngineError::new(
599 400,
600 "INVALID_INPUT",
601 format!(
602 "the schedule input is invalid for {}@{} at {e}",
603 def.name, def.version
604 ),
605 )
606 })?;
607 Ok((spec, plan))
608 }
609
610 fn storage_err(e: crate::store::StoreError) -> EngineError {
611 EngineError::new(500, "STORAGE_ERROR", e.to_string())
612 }
613
614 fn require_writable(&self) -> Result<()> {
615 if self.store().read_only() {
616 return Err(EngineError::new(
617 503,
618 "NOT_LEADER",
619 "this server is a standby: schedules are changed on the leader",
620 ));
621 }
622 Ok(())
623 }
624
625 pub fn create_schedule(&mut self, req: CreateSchedule) -> Result<Value> {
627 self.require_writable()?;
628 if !valid_schedule_id(&req.schedule_id) {
629 return Err(invalid(
630 "schedule ids may contain letters, digits and - _ . (at most 100 characters)",
631 ));
632 }
633 let (spec, plan) = self.parse_spec(req.spec)?;
634 let now = self.now();
635 let next = plan.next_after(now);
636 let row = ScheduleRow {
637 schedule_id: req.schedule_id.clone(),
638 spec: serde_json::to_string(&spec).expect("spec serialises"),
639 paused: req.paused,
640 next_fire_ms: next,
641 due_ms: next.map(|n| n + plan.jitter_for(&req.schedule_id, n)),
642 last_fire_ms: None,
643 last_execution_id: None,
644 fired: 0,
645 skipped: 0,
646 last_error: None,
647 created_at_ms: now,
648 updated_at_ms: now,
649 };
650 if !self
651 .store()
652 .insert_schedule(&row)
653 .map_err(Self::storage_err)?
654 {
655 return Err(EngineError::new(
656 409,
657 "SCHEDULE_EXISTS",
658 format!("schedule `{}` already exists", req.schedule_id),
659 ));
660 }
661 self.schedules_dirty = true;
662 tracing::info!(schedule_id = %row.schedule_id, workflow = %spec.workflow, next = ?next.map(format_time), "schedule created");
663 Ok(view(&row))
664 }
665
666 pub fn schedule_row(&self, id: &str) -> Result<ScheduleRow> {
667 self.store()
668 .schedule(id)
669 .map_err(Self::storage_err)?
670 .ok_or_else(|| not_found(id))
671 }
672
673 pub fn get_schedule(&self, id: &str) -> Result<Value> {
674 Ok(view(&self.schedule_row(id)?))
675 }
676
677 pub fn schedule_workflow(&self, id: &str) -> Result<String> {
679 let row = self.schedule_row(id)?;
680 Ok(serde_json::from_str::<Value>(&row.spec)
681 .ok()
682 .and_then(|v| v["workflow"].as_str().map(str::to_string))
683 .unwrap_or_default())
684 }
685
686 pub fn list_schedules(&self) -> Result<Vec<Value>> {
687 Ok(self
688 .store()
689 .schedules()
690 .map_err(Self::storage_err)?
691 .iter()
692 .map(view)
693 .collect())
694 }
695
696 pub fn update_schedule(&mut self, id: &str, patch: Value) -> Result<Value> {
700 self.require_writable()?;
701 let Value::Object(patch) = patch else {
702 return Err(invalid("expected a JSON object"));
703 };
704 let mut row = self.schedule_row(id)?;
705 let mut spec: Value = serde_json::from_str(&row.spec).unwrap_or_else(|_| json!({}));
706 for (k, v) in patch {
707 match k.as_str() {
708 "schedule_id" => {
709 if v.as_str() != Some(id) {
710 return Err(invalid("a schedule's id cannot be changed"));
711 }
712 }
713 "paused" => {
714 row.paused = v
715 .as_bool()
716 .ok_or_else(|| invalid("`paused` must be true or false"))?
717 }
718 _ if v.is_null() => {
719 spec.as_object_mut().expect("object").remove(&k);
720 }
721 _ => {
722 spec.as_object_mut().expect("object").insert(k, v);
723 }
724 }
725 }
726 let (spec, plan) = self.parse_spec(spec)?;
728 let now = self.now();
729 let next = plan.next_after(now);
730 row.spec = serde_json::to_string(&spec).expect("spec serialises");
731 row.next_fire_ms = next;
732 row.due_ms = next.map(|n| n + plan.jitter_for(id, n));
733 row.last_error = None;
734 row.updated_at_ms = now;
735 if !self
736 .store()
737 .update_schedule(&row)
738 .map_err(Self::storage_err)?
739 {
740 return Err(not_found(id));
741 }
742 self.schedules_dirty = true;
743 tracing::info!(schedule_id = %id, paused = row.paused, next = ?next.map(format_time), "schedule updated");
744 Ok(view(&row))
745 }
746
747 pub fn set_schedule_paused(&mut self, id: &str, paused: bool) -> Result<Value> {
748 self.update_schedule(id, json!({ "paused": paused }))
749 }
750
751 pub fn delete_schedule(&mut self, id: &str) -> Result<()> {
752 self.require_writable()?;
753 if !self
754 .store()
755 .delete_schedule(id)
756 .map_err(Self::storage_err)?
757 {
758 return Err(not_found(id));
759 }
760 tracing::info!(schedule_id = %id, "schedule deleted");
761 Ok(())
762 }
763
764 pub fn trigger_schedule(&mut self, id: &str) -> Result<Triggered> {
767 self.require_writable()?;
768 let row = self.schedule_row(id)?;
769 let spec: ScheduleSpec = serde_json::from_str(&row.spec)
770 .map_err(|e| invalid(format!("stored schedule is unreadable: {e}")))?;
771 let now = self.now();
772 let exec_id = format!("{id}:manual:{}", format_time(now));
773 match self.fire_policy(&spec, id, row.last_execution_id.as_deref())? {
774 false => {
775 self.metrics.schedules_skipped_overlap += 1;
776 Ok(Triggered {
777 schedule_id: id.to_string(),
778 started: false,
779 execution_id: None,
780 skipped: Some("overlap".into()),
781 })
782 }
783 true => {
784 let r = self.start_scheduled(
785 &spec.workflow,
786 spec.version.as_deref(),
787 spec.input.clone(),
788 exec_id,
789 id,
790 )?;
791 self.store()
792 .record_schedule_trigger(id, &r.execution_id, now)
793 .map_err(Self::storage_err)?;
794 if r.created {
795 self.metrics.schedules_fired += 1;
796 }
797 tracing::info!(schedule_id = %id, execution_id = %r.execution_id, "schedule triggered");
798 Ok(Triggered {
799 schedule_id: id.to_string(),
800 started: true,
801 execution_id: Some(r.execution_id),
802 skipped: None,
803 })
804 }
805 }
806 }
807
808 fn fire_policy(&mut self, spec: &ScheduleSpec, id: &str, last: Option<&str>) -> Result<bool> {
811 if spec.overlap == Overlap::Allow {
812 return Ok(true);
813 }
814 let Some(last) = last else { return Ok(true) };
815 let running = self
816 .store()
817 .execution_status(last)
818 .map_err(Self::storage_err)?
819 .is_some_and(|s| !terminal(&s));
820 if !running {
821 return Ok(true);
822 }
823 match spec.overlap {
824 Overlap::Skip => {
825 tracing::info!(schedule_id = %id, running = %last, "schedule occurrence skipped: the previous execution is still running");
826 Ok(false)
827 }
828 _ => {
829 tracing::info!(schedule_id = %id, cancelling = %last, "schedule: cancelling the previous execution (overlap cancel_previous)");
830 match self.cancel(last, &format!("schedule `{id}`: replaced by the next occurrence (overlap cancel_previous)")) {
831 Ok(_) => {}
832 Err(e) if e.code == "EXECUTION_FINISHED" => {}
833 Err(e) => return Err(e),
834 }
835 Ok(true)
836 }
837 }
838 }
839
840 pub fn fire_schedules(&mut self) -> Result<usize> {
843 if self.store().read_only() {
844 return Ok(0);
845 }
846 let now = self.now();
847 if now < self.schedule_poll_at && !self.schedules_dirty {
848 return Ok(0);
849 }
850 self.schedule_poll_at = now + POLL_MS;
851 self.schedules_dirty = false;
852 let due = self.store().due_schedules(now).map_err(Self::storage_err)?;
853 let mut handled = 0;
854 for row in due {
855 match self.fire_one(&row, now) {
856 Ok(true) => handled += 1,
857 Ok(false) => {}
858 Err(e) => {
859 tracing::warn!(schedule_id = %row.schedule_id, error = %e, "firing a schedule failed")
860 }
861 }
862 }
863 Ok(handled)
864 }
865
866 fn fire_one(&mut self, row: &ScheduleRow, now: u64) -> Result<bool> {
869 let id = row.schedule_id.as_str();
870 let Some(occurrence) = row.next_fire_ms else {
871 return Ok(false);
872 };
873 let node = self.instance_id().to_string();
874 if !self
875 .store()
876 .claim_schedule(id, occurrence, &node, now, now + CLAIM_MS)
877 .map_err(Self::storage_err)?
878 {
879 return Ok(false);
880 }
881 let parsed = serde_json::from_str::<ScheduleSpec>(&row.spec)
882 .map_err(|e| invalid(e.to_string()))
883 .and_then(|s| s.plan().map(|p| (s, p)));
884 let (spec, plan) = match parsed {
885 Ok(x) => x,
886 Err(e) => {
887 let _ =
888 self.store()
889 .release_schedule_claim(id, &node, now + CLAIM_MS, &e.message, now);
890 return Err(e);
891 }
892 };
893 let mut latest = occurrence;
896 let mut missed = 0u64;
897 while missed < MAX_MISSED {
898 match plan.next_after(latest) {
899 Some(n) if n <= now => {
900 missed += 1;
901 latest = n;
902 }
903 _ => break,
904 }
905 }
906 let late = now.saturating_sub(latest + plan.jitter_for(id, latest));
907 let candidate = match spec.catchup {
908 Catchup::RunOnce => Some(latest),
909 Catchup::SkipMissed if late <= MISSED_AFTER_MS => Some(latest),
910 Catchup::SkipMissed => {
911 missed += 1;
912 None
913 }
914 };
915 let mut record = FireRecord {
916 next_fire_ms: plan.next_after(latest.max(now)),
917 last_fire_ms: Some(latest),
918 skipped: missed,
919 ..FireRecord::default()
920 };
921 record.due_ms = record.next_fire_ms.map(|n| n + plan.jitter_for(id, n));
922 let mut started = None;
923 let mut skipped_overlap = false;
924 if let Some(at) = candidate {
925 let go = match self.fire_policy(&spec, id, row.last_execution_id.as_deref()) {
926 Ok(go) => go,
927 Err(e) => {
928 let _ = self.store().release_schedule_claim(
929 id,
930 &node,
931 now + RETRY_MS,
932 &e.message,
933 now,
934 );
935 return Err(e);
936 }
937 };
938 if go {
939 let exec_id = fire_execution_id(id, at);
940 match self.start_scheduled(
941 &spec.workflow,
942 spec.version.as_deref(),
943 spec.input.clone(),
944 exec_id,
945 id,
946 ) {
947 Ok(r) => {
948 record.fired = 1;
949 record.last_execution_id = Some(r.execution_id.clone());
950 started = Some(r);
951 }
952 Err(e) => {
953 let _ = self.store().release_schedule_claim(
955 id,
956 &node,
957 now + RETRY_MS,
958 &e.message,
959 now,
960 );
961 return Err(e);
962 }
963 }
964 } else {
965 skipped_overlap = true;
966 record.skipped += 1;
967 }
968 }
969 let recorded = self
970 .store()
971 .finish_schedule_fire(id, occurrence, &node, now, &record)
972 .map_err(Self::storage_err)?;
973 if !recorded {
974 tracing::warn!(schedule_id = %id, "schedule changed while it fired; its new settings apply from now on");
975 }
976 if let Some(r) = &started {
977 if r.created {
978 self.metrics.schedules_fired += 1;
979 }
980 tracing::info!(schedule_id = %id, execution_id = %r.execution_id, created = r.created, next = ?record.next_fire_ms.map(format_time), "schedule fired");
981 }
982 if skipped_overlap {
983 self.metrics.schedules_skipped_overlap += 1;
984 }
985 let missed_count = if skipped_overlap {
986 record.skipped - 1
987 } else {
988 record.skipped
989 };
990 if missed_count > 0 {
991 self.metrics.schedules_skipped_missed += missed_count;
992 tracing::warn!(schedule_id = %id, missed = missed_count, catchup = ?spec.catchup, "schedule occurrences were missed while no server could fire them");
993 }
994 Ok(true)
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn ms(t: &str) -> u64 {
1003 parse_time(t).unwrap()
1004 }
1005
1006 fn next(cron: &str, tz: &str, after: &str) -> String {
1007 let c = Cron::parse(cron).unwrap();
1008 format_time(
1009 c.next_after(ms(after), &TimeZone::get(tz).unwrap())
1010 .unwrap(),
1011 )
1012 }
1013
1014 #[test]
1015 fn cron_fields() {
1016 assert_eq!(
1017 next("0 2 * * *", "UTC", "2026-11-15T01:59:59Z"),
1018 "2026-11-15T02:00:00Z"
1019 );
1020 assert_eq!(
1022 next("0 2 * * *", "UTC", "2026-11-15T02:00:00Z"),
1023 "2026-11-16T02:00:00Z"
1024 );
1025 assert_eq!(
1026 next("*/15 * * * *", "UTC", "2026-11-15T10:07:00Z"),
1027 "2026-11-15T10:15:00Z"
1028 );
1029 assert_eq!(
1030 next("30 9 * * MON-FRI", "UTC", "2026-11-14T00:00:00Z"),
1031 "2026-11-16T09:30:00Z"
1032 );
1033 assert_eq!(
1034 next("0 0 1 JAN,jul *", "UTC", "2026-02-01T00:00:00Z"),
1035 "2026-07-01T00:00:00Z"
1036 );
1037 assert_eq!(
1038 next("0 12 * * 7", "UTC", "2026-11-15T13:00:00Z"),
1039 "2026-11-22T12:00:00Z"
1040 );
1041 assert_eq!(
1042 next("0 0 29 2 *", "UTC", "2026-01-01T00:00:00Z"),
1043 "2028-02-29T00:00:00Z"
1044 );
1045 assert_eq!(
1047 next("0 0 13 * 5", "UTC", "2026-11-01T00:00:00Z"),
1048 "2026-11-06T00:00:00Z"
1049 );
1050 assert_eq!(
1051 next("@hourly", "UTC", "2026-11-15T10:00:00Z"),
1052 "2026-11-15T11:00:00Z"
1053 );
1054 assert_eq!(
1055 next("5/20 * * * *", "UTC", "2026-11-15T10:06:00Z"),
1056 "2026-11-15T10:25:00Z"
1057 );
1058 }
1059
1060 #[test]
1061 fn cron_time_zones_and_daylight_saving() {
1062 assert_eq!(
1064 next("0 2 * * *", "Europe/Berlin", "2026-11-15T00:00:00Z"),
1065 "2026-11-15T01:00:00Z"
1066 );
1067 assert_eq!(
1069 next("30 2 * * *", "Europe/Berlin", "2026-03-29T00:00:00Z"),
1070 "2026-03-29T01:30:00Z"
1071 );
1072 let c = Cron::parse("30 2 * * *").unwrap();
1074 let tz = TimeZone::get("Europe/Berlin").unwrap();
1075 let first = c.next_after(ms("2026-10-24T12:00:00Z"), &tz).unwrap();
1076 assert_eq!(format_time(first), "2026-10-25T00:30:00Z");
1077 assert_eq!(
1078 format_time(c.next_after(first, &tz).unwrap()),
1079 "2026-10-26T01:30:00Z"
1080 );
1081 }
1082
1083 #[test]
1084 fn cron_errors() {
1085 for (bad, msg) in [
1086 ("0 2 * *", "expected 5 fields"),
1087 ("60 * * * *", "out of range"),
1088 ("* 24 * * *", "out of range"),
1089 ("* * 0 * *", "out of range"),
1090 ("* * * FOO *", "not a valid month"),
1091 ("*/0 * * * *", "at least 1"),
1092 ("@often", "unknown macro"),
1093 ] {
1094 let e = Cron::parse(bad).unwrap_err();
1095 assert!(e.contains(msg), "{bad}: {e}");
1096 }
1097 }
1098
1099 #[test]
1100 fn intervals_start_end_and_jitter() {
1101 let spec = |extra: Value| -> Plan {
1102 let mut v = json!({ "workflow": "W", "interval": "15m" });
1103 v.as_object_mut()
1104 .unwrap()
1105 .extend(extra.as_object().unwrap().clone());
1106 serde_json::from_value::<ScheduleSpec>(v)
1107 .unwrap()
1108 .plan()
1109 .unwrap()
1110 };
1111 let p = spec(json!({}));
1112 assert_eq!(
1113 format_time(p.next_after(ms("2026-11-15T10:07:00Z")).unwrap()),
1114 "2026-11-15T10:15:00Z"
1115 );
1116 let p =
1117 spec(json!({ "start_at": "2026-11-15T10:01:00Z", "end_at": "2026-11-15T10:40:00Z" }));
1118 let all: Vec<String> = p.upcoming(0, 10).into_iter().map(format_time).collect();
1119 assert_eq!(
1120 all,
1121 [
1122 "2026-11-15T10:01:00Z",
1123 "2026-11-15T10:16:00Z",
1124 "2026-11-15T10:31:00Z"
1125 ]
1126 );
1127 let p = spec(json!({ "jitter": "30s" }));
1128 let j = p.jitter_for("s", 1000);
1129 assert!(j < 30_000);
1130 assert_eq!(j, p.jitter_for("s", 1000));
1131 assert_eq!(parse_duration("1h30m").unwrap(), 5_400_000);
1132 assert!(parse_duration("15").is_err());
1133 assert!(parse_duration("5w").is_err());
1134 let bad = |v: Value| {
1135 serde_json::from_value::<ScheduleSpec>(v)
1136 .unwrap()
1137 .plan()
1138 .unwrap_err()
1139 .message
1140 };
1141 assert!(bad(json!({ "workflow": "W" })).contains("needs `cron` or `interval`"));
1142 assert!(bad(json!({ "workflow": "W", "interval": "500ms" })).contains("at least 1s"));
1143 assert!(
1144 bad(json!({ "workflow": "W", "cron": "* * * * *", "timezone": "Mars/Base" }))
1145 .contains("unknown time zone")
1146 );
1147 }
1148}