Skip to main content

databricks_tui/
schedule.rs

1//! Next-fire computation for job schedules: Quartz cron expressions,
2//! periodic triggers, file-arrival triggers and continuous mode.
3
4use chrono::TimeZone;
5use chrono_tz::Tz;
6use cron::Schedule;
7use serde_json::Value;
8use std::str::FromStr;
9
10/// When a job will run next, derived from its settings.
11pub struct NextRun {
12    /// Epoch ms of the next execution; None when it can't be predicted
13    /// (paused, file-arrival, unparseable cron).
14    pub at_ms: Option<u64>,
15    /// Short label for list rows, e.g. "in 27m", "paused", "on file arrival".
16    pub label: String,
17    /// Longer description for the upcoming view, e.g. the cron expression.
18    pub desc: String,
19    pub paused: bool,
20}
21
22/// Compact countdown to a future epoch-ms instant, e.g. "in 2h 15m".
23pub fn fmt_eta(at_ms: u64, now_ms: u64) -> String {
24    let secs = at_ms.saturating_sub(now_ms) / 1000;
25    match secs {
26        0..=59 => "in <1m".to_string(),
27        60..=3599 => format!("in {}m", secs / 60),
28        3600..=86_399 => format!("in {}h {}m", secs / 3600, (secs % 3600) / 60),
29        _ => format!("in {}d", secs / 86_400),
30    }
31}
32
33/// Next fire of a Quartz cron expression in an IANA timezone, as epoch ms.
34/// Unknown timezones fall back to UTC; an unparseable cron yields None.
35pub fn next_fire(cron: &str, tz: &str, now_ms: u64) -> Option<u64> {
36    let schedule = Schedule::from_str(cron.trim()).ok()?;
37    let tz: Tz = tz.parse().unwrap_or(chrono_tz::UTC);
38    let now = tz.timestamp_millis_opt(now_ms as i64).single()?;
39    schedule
40        .after(&now)
41        .next()
42        .map(|dt| dt.timestamp_millis() as u64)
43}
44
45/// Derives the next run from a job's settings (`schedule`, `trigger`,
46/// `continuous`), using the last run start for periodic triggers.
47/// None for jobs that only run on demand.
48pub fn next_run(settings: &Value, last_start_ms: Option<u64>, now_ms: u64) -> Option<NextRun> {
49    if let Some(cron) = settings["schedule"]["quartz_cron_expression"].as_str() {
50        let tz = settings["schedule"]["timezone_id"]
51            .as_str()
52            .unwrap_or("UTC");
53        let paused = settings["schedule"]["pause_status"].as_str() == Some("PAUSED");
54        let desc = format!("cron {cron} · {tz}");
55        if paused {
56            return Some(NextRun {
57                at_ms: None,
58                label: "⏸ paused".to_string(),
59                desc,
60                paused: true,
61            });
62        }
63        return Some(match next_fire(cron, tz, now_ms) {
64            Some(at) => NextRun {
65                at_ms: Some(at),
66                label: fmt_eta(at, now_ms),
67                desc,
68                paused: false,
69            },
70            None => NextRun {
71                at_ms: None,
72                label: "scheduled".to_string(),
73                desc,
74                paused: false,
75            },
76        });
77    }
78
79    let continuous = &settings["continuous"];
80    if continuous.is_object() {
81        let paused = continuous["pause_status"].as_str() == Some("PAUSED");
82        return Some(NextRun {
83            at_ms: None,
84            label: if paused { "⏸ paused" } else { "continuous" }.to_string(),
85            desc: "continuous".to_string(),
86            paused,
87        });
88    }
89
90    let trigger = &settings["trigger"];
91    if trigger.is_object() {
92        let paused = trigger["pause_status"].as_str() == Some("PAUSED");
93        if let (Some(n), Some(unit)) = (
94            trigger["periodic"]["interval"].as_u64(),
95            trigger["periodic"]["unit"].as_str(),
96        ) {
97            let unit_ms = match unit {
98                "HOURS" => 3_600_000,
99                "DAYS" => 86_400_000,
100                "WEEKS" => 604_800_000,
101                _ => 0,
102            };
103            let unit_word = unit.to_lowercase();
104            let unit_word = if n == 1 {
105                unit_word.trim_end_matches('s')
106            } else {
107                &unit_word
108            };
109            let desc = format!("every {n} {unit_word}");
110            if paused {
111                return Some(NextRun {
112                    at_ms: None,
113                    label: "⏸ paused".to_string(),
114                    desc,
115                    paused: true,
116                });
117            }
118            // The API doesn't expose the next fire directly; estimate it
119            // from the last run's start.
120            let at = last_start_ms
121                .filter(|_| unit_ms > 0)
122                .map(|s| s + n * unit_ms)
123                .filter(|at| *at > now_ms);
124            return Some(match at {
125                Some(at) => NextRun {
126                    at_ms: Some(at),
127                    label: format!("~{}", fmt_eta(at, now_ms)),
128                    desc,
129                    paused: false,
130                },
131                None => NextRun {
132                    at_ms: None,
133                    label: desc.clone(),
134                    desc,
135                    paused: false,
136                },
137            });
138        }
139        if trigger["file_arrival"].is_object() {
140            return Some(NextRun {
141                at_ms: None,
142                label: if paused {
143                    "⏸ paused"
144                } else {
145                    "on file arrival"
146                }
147                .to_string(),
148                desc: "file arrival trigger".to_string(),
149                paused,
150            });
151        }
152    }
153    None
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    // 2026-07-18 12:00:00 UTC
162    const NOW: u64 = 1_784_376_000_000;
163
164    #[test]
165    fn quartz_daily_cron_fires_next_day() {
166        // Daily at 02:00 UTC — next fire is tomorrow 02:00.
167        let at = next_fire("0 0 2 * * ?", "UTC", NOW).unwrap();
168        assert!(at > NOW);
169        assert_eq!((at - NOW) % 86_400_000 % 3_600_000, 0);
170        assert!(at - NOW < 86_400_000);
171    }
172
173    #[test]
174    fn timezone_shifts_the_fire_time() {
175        let utc = next_fire("0 0 2 * * ?", "UTC", NOW).unwrap();
176        let tokyo = next_fire("0 0 2 * * ?", "Asia/Tokyo", NOW).unwrap();
177        assert_ne!(utc, tokyo);
178    }
179
180    #[test]
181    fn bad_cron_is_none() {
182        assert!(next_fire("not a cron", "UTC", NOW).is_none());
183    }
184
185    #[test]
186    fn paused_schedule_has_no_time() {
187        let s = json!({"schedule": {
188            "quartz_cron_expression": "0 0 2 * * ?",
189            "timezone_id": "UTC",
190            "pause_status": "PAUSED"
191        }});
192        let n = next_run(&s, None, NOW).unwrap();
193        assert!(n.paused);
194        assert!(n.at_ms.is_none());
195    }
196
197    #[test]
198    fn periodic_trigger_estimates_from_last_start() {
199        let s = json!({"trigger": {"periodic": {"interval": 4, "unit": "HOURS"}}});
200        let n = next_run(&s, Some(NOW - 3_600_000), NOW).unwrap();
201        assert_eq!(n.at_ms, Some(NOW + 3 * 3_600_000));
202        assert_eq!(n.label, "~in 3h 0m");
203    }
204
205    #[test]
206    fn on_demand_job_is_none() {
207        assert!(next_run(&json!({"name": "adhoc"}), None, NOW).is_none());
208    }
209
210    #[test]
211    fn eta_formats() {
212        assert_eq!(fmt_eta(NOW + 30_000, NOW), "in <1m");
213        assert_eq!(fmt_eta(NOW + 27 * 60_000, NOW), "in 27m");
214        assert_eq!(fmt_eta(NOW + 3 * 86_400_000, NOW), "in 3d");
215    }
216}