Skip to main content

orbit_runtime/
schedule.rs

1//! Schedules: workflows started by a cron expression or a fixed interval.
2//!
3//! A schedule is a row in `orbit_schedules` (see [`crate::store`]): its
4//! specification (workflow, input, timing and policies) as JSON, and its
5//! progress (the next occurrence, the last execution, counters). Any server
6//! that can write fires due occurrences from its tick:
7//!
8//! 1. **Claim.** `UPDATE ... WHERE next_fire_ms = <the occurrence> AND <no
9//!    live claim>` sets a claim with a lease. Of several servers that find
10//!    the same occurrence due (shared mode), exactly one gets it.
11//! 2. **Fire.** The overlap and catch-up policies decide whether to start an
12//!    execution. Its id is `<schedule-id>:<occurrence, RFC 3339 UTC>`, so a
13//!    start repeated after a crash is the existing idempotent start.
14//! 3. **Advance.** A second compare-and-set records the outcome, moves
15//!    `next_fire_ms` to the following occurrence and drops the claim.
16//!
17//! A server that dies between 1 and 3 leaves a claim that expires after
18//! [`CLAIM_MS`]; then any server takes the occurrence again, and the
19//! deterministic id makes the second start a no-op.
20//!
21//! Times are milliseconds since the Unix epoch. Cron expressions are
22//! evaluated in the schedule's IANA time zone (UTC by default) with the
23//! time-zone database bundled into the binary.
24
25use 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
34/// How long a server's claim on an occurrence lasts.
35pub const CLAIM_MS: u64 = 30_000;
36/// After a failed start, the occurrence is retried this much later.
37pub const RETRY_MS: u64 = 5_000;
38/// An occurrence handled more than this late counts as missed (catch-up policy).
39pub const MISSED_AFTER_MS: u64 = 60_000;
40/// Due schedules are looked up at most this often (and when one changes on this server).
41pub const POLL_MS: u64 = 1_000;
42/// Missed occurrences counted one by one at most (a longer outage counts this many).
43const MAX_MISSED: u64 = 100_000;
44
45/// What happens when an occurrence is due while the previous execution this
46/// schedule started is still running.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Overlap {
50    /// Do not start; count the occurrence as skipped.
51    #[default]
52    Skip,
53    /// Start anyway: executions of the schedule may run at the same time.
54    Allow,
55    /// Cancel the previous execution (its compensation runs), then start.
56    CancelPrevious,
57}
58
59/// What happens to occurrences that passed while no server could fire them.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Catchup {
63    /// Skip them; firing resumes with the next occurrence.
64    #[default]
65    SkipMissed,
66    /// Start one execution for the most recent missed occurrence.
67    RunOnce,
68}
69
70/// A schedule's specification: the JSON of `POST /v1/schedules` (without
71/// `schedule_id` and `paused`), stored in `orbit_schedules.spec`.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct ScheduleSpec {
75    pub workflow: String,
76    /// Default: the latest registered version at each fire.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub version: Option<String>,
79    #[serde(default)]
80    pub input: Value,
81    /// Five fields: minute hour day-of-month month day-of-week.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub cron: Option<String>,
84    /// A fixed interval such as `15m` or `1h30m`, aligned to `start_at`
85    /// (or to the Unix epoch).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub interval: Option<String>,
88    /// IANA time zone for `cron`, e.g. `Europe/Berlin`. Default UTC.
89    #[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    /// RFC 3339: no occurrence before this time.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub start_at: Option<String>,
98    /// RFC 3339: no occurrence after this time; the schedule then ends.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub end_at: Option<String>,
101    /// Delay each fire by a pseudo-random amount below this duration,
102    /// derived from the schedule id and the occurrence (the same on every server).
103    #[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/// When a schedule fires, compiled from its specification.
114#[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    /// Check the timing fields and compile them.
130    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    /// The first occurrence strictly after `t`, if the schedule has one.
192    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    /// The next `n` occurrences after `t`.
215    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    /// The fire delay of an occurrence: a deterministic value below the
231    /// jitter, from the schedule id and the occurrence.
232    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// --------------------------------------------------------------------- cron
244
245/// A parsed five-field cron expression (minute hour day-of-month month
246/// day-of-week), with the usual extensions: lists, ranges, steps, month and
247/// weekday names, `7` for Sunday, and the `@hourly` ... `@yearly` macros.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct Cron {
250    minutes: u64,
251    hours: u32,
252    days: u32,
253    months: u16,
254    weekdays: u8,
255    /// The day-of-month / day-of-week field starts with `*`: as in Vixie
256    /// cron, a day matches both fields when either is `*`, and either field
257    /// when both are restricted.
258    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            // `5/15` means from 5 to the end, every 15.
302            (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    /// The first matching minute strictly after `t` (ms), in `tz`. A local
370    /// time skipped by a daylight-saving change fires at the corresponding
371    /// time after the change; a local time that occurs twice fires once, at
372    /// its first occurrence.
373    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        // The first whole minute after `t`.
377        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        // Eight years covers every valid expression (29 February included).
385        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
423// ------------------------------------------------------------- formatting
424
425/// `15m`, `1h30m`, `500ms`, `2d`: a sum of whole numbers with units.
426pub 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
462/// Parse an RFC 3339 timestamp to milliseconds since the Unix epoch.
463pub 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
470/// Milliseconds since the Unix epoch as RFC 3339 in UTC.
471pub 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
477/// The execution id of an occurrence: `<schedule-id>:<occurrence>`.
478pub fn fire_execution_id(schedule_id: &str, occurrence: u64) -> String {
479    format!("{schedule_id}:{}", format_time(occurrence))
480}
481
482/// Schedule ids: letters, digits, `-`, `_` and `.`, at most 100 characters.
483pub 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
491/// The API view of a schedule: its specification, its state and its next
492/// fire times.
493pub 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// ----------------------------------------------------------------- engine
540
541/// Body of `POST /v1/schedules`.
542#[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/// The result of firing a schedule now (`POST /v1/schedules/{id}/trigger`).
552#[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    /// Why nothing started (`overlap`).
559    #[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    /// Create a schedule. Its first occurrence is the first one after now.
626    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    /// The workflow a schedule starts (for authorization).
678    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    /// Change a schedule: `patch` holds the fields to change (`null` clears
697    /// an optional one; `paused` pauses or resumes). The next occurrence is
698    /// recomputed from now, so occurrences missed while paused are skipped.
699    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        // Switching between cron and interval: the new one replaces the old.
727        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    /// Fire a schedule now, outside its timing (the overlap policy applies).
765    /// The execution id is `<schedule-id>:manual:<now>`.
766    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    /// Apply the overlap policy before a start: false means skip. With
809    /// `cancel_previous`, a running previous execution is cancelled here.
810    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    /// Fire every due occurrence (called from [`Engine::tick`]). Looks the
841    /// store up at most every [`POLL_MS`], unless a schedule changed here.
842    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    /// Claim, fire and advance one due schedule. False if another server
867    /// holds the occurrence.
868    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        // Every occurrence up to now: the last one is the candidate, the
894        // earlier ones were missed.
895        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                        // Not advanced: the same occurrence is tried again.
954                        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        // Strictly after: an occurrence at exactly `t` is not returned.
1021        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        // Both day fields restricted: either matches (the 13th, or a Friday).
1046        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        // 02:00 in Berlin is 01:00 UTC in winter.
1063        assert_eq!(
1064            next("0 2 * * *", "Europe/Berlin", "2026-11-15T00:00:00Z"),
1065            "2026-11-15T01:00:00Z"
1066        );
1067        // 2026-03-29: 02:30 does not exist in Berlin; it fires at 03:30 CEST.
1068        assert_eq!(
1069            next("30 2 * * *", "Europe/Berlin", "2026-03-29T00:00:00Z"),
1070            "2026-03-29T01:30:00Z"
1071        );
1072        // 2026-10-25: 02:30 happens twice; it fires once, at the first.
1073        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}