Skip to main content

microclaw_engine/
schedule_lifecycle.rs

1//! Scheduled-task lifecycle: run caps, deadlines, and random ("spontaneous")
2//! cadences.
3//!
4//! Design rule: flexibility never bends the state machine. Every lifecycle
5//! feature resolves to the SAME primitive the scheduler already trusts —
6//! "either there is a concrete `next_run`, or the task transitions to
7//! `completed` with a logged reason". No new statuses, no background
8//! bookkeeping; all decisions are pure functions over the task row, so every
9//! edge is unit-testable without a database or a clock.
10//!
11//! Cadence types:
12//! - `cron`   — unchanged (6-field expression, timezone-aware).
13//! - `once`   — unchanged (single ISO timestamp).
14//! - `random` — NEW: `schedule_value` is a duration range like `"2h..3d"`;
15//!   after each run the next firing lands uniformly at random inside the
16//!   window. Units: `s`, `m`, `h`, `d`, `w`, `mo` (≈30d). The window is
17//!   clamped to [1 minute, 366 days] so a typo can neither hot-loop the
18//!   scheduler nor silence a task for years.
19//!
20//! Lifecycle limits (orthogonal, apply to any cadence):
21//! - `max_runs`  — retire the task as `completed` once it has run N times.
22//! - `not_after` — retire the task as `completed` once the next firing would
23//!   land past the deadline (checked again at claim time, so a task that was
24//!   already queued cannot fire after its deadline either).
25
26use chrono::{DateTime, Duration, Utc};
27
28/// Outcome of post-run scheduling for a recurring task.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum NextRunDecision {
31    /// Keep going: reschedule at this RFC 3339 instant.
32    RunAt(String),
33    /// Retire the task as `completed`, with a human-readable reason for logs.
34    Finished(String),
35}
36
37/// Bounds for a `random` schedule window.
38const MIN_RANDOM_INTERVAL: i64 = 60; // seconds
39const MAX_RANDOM_INTERVAL: i64 = 366 * 24 * 3600;
40
41/// Parse a single duration like `45s`, `30m`, `2h`, `3d`, `1w`, `2mo`.
42pub fn parse_duration(s: &str) -> Result<Duration, String> {
43    let s = s.trim();
44    let split = s
45        .find(|c: char| !c.is_ascii_digit())
46        .ok_or_else(|| format!("duration `{s}` is missing a unit (s/m/h/d/w/mo)"))?;
47    let (num, unit) = s.split_at(split);
48    let n: i64 = num
49        .parse()
50        .map_err(|_| format!("duration `{s}` has an invalid number"))?;
51    if n <= 0 {
52        return Err(format!("duration `{s}` must be positive"));
53    }
54    let seconds = match unit {
55        "s" => n,
56        "m" => n * 60,
57        "h" => n * 3600,
58        "d" => n * 86_400,
59        "w" => n * 7 * 86_400,
60        "mo" => n * 30 * 86_400,
61        other => {
62            return Err(format!(
63                "duration `{s}` has unknown unit `{other}` (use s/m/h/d/w/mo)"
64            ))
65        }
66    };
67    Ok(Duration::seconds(seconds))
68}
69
70/// Parse a `random` schedule window: `"2h..3d"` (also accepts `"2h-3d"`).
71/// Clamped to [1 minute, 366 days]; min must not exceed max.
72pub fn parse_duration_range(spec: &str) -> Result<(Duration, Duration), String> {
73    let spec = spec.trim();
74    let (lo, hi) = spec
75        .split_once("..")
76        .or_else(|| spec.split_once('-'))
77        .ok_or_else(|| {
78            format!("random schedule `{spec}` must be a range like `2h..3d` or `30m..4h`")
79        })?;
80    let min = parse_duration(lo)?;
81    let max = parse_duration(hi)?;
82    if min.num_seconds() < MIN_RANDOM_INTERVAL {
83        return Err(format!(
84            "random window minimum `{lo}` is below 1 minute — that would hot-loop the scheduler"
85        ));
86    }
87    if max.num_seconds() > MAX_RANDOM_INTERVAL {
88        return Err(format!(
89            "random window maximum `{hi}` exceeds 366 days — pick a shorter window"
90        ));
91    }
92    if min > max {
93        return Err(format!("random window `{spec}` has min > max"));
94    }
95    Ok((min, max))
96}
97
98/// Uniform sample inside the window. `unit` is injected (0.0 ≤ unit < 1.0)
99/// so tests are deterministic; production passes [`random_unit`].
100pub fn sample_random_next(
101    now: DateTime<Utc>,
102    min: Duration,
103    max: Duration,
104    unit: f64,
105) -> DateTime<Utc> {
106    let span = (max - min).num_seconds().max(0);
107    let offset = min.num_seconds() + ((span as f64) * unit.clamp(0.0, 0.999_999)) as i64;
108    now + Duration::seconds(offset)
109}
110
111/// Entropy in [0, 1) without adding an RNG dependency: the v4 UUID source is
112/// already a CSPRNG. Scheduling jitter needs unpredictability, not statistics.
113pub fn random_unit() -> f64 {
114    let bytes = *uuid::Uuid::new_v4().as_bytes();
115    let mut v: u64 = 0;
116    for b in &bytes[..8] {
117        v = (v << 8) | u64::from(*b);
118    }
119    // 53 usable mantissa bits keeps the conversion exact.
120    (v >> 11) as f64 / (1u64 << 53) as f64
121}
122
123/// Decide what happens to a recurring (`cron` | `random`) task after a run.
124/// `run_count_after` is the count INCLUDING the run that just finished.
125#[allow(clippy::too_many_arguments)]
126pub fn decide_next_run(
127    schedule_type: &str,
128    schedule_value: &str,
129    tz: chrono_tz::Tz,
130    now: DateTime<Utc>,
131    run_count_after: i64,
132    max_runs: Option<i64>,
133    not_after: Option<DateTime<Utc>>,
134    unit: f64,
135) -> NextRunDecision {
136    if let Some(max) = max_runs {
137        if run_count_after >= max {
138            return NextRunDecision::Finished(format!(
139                "reached max_runs ({run_count_after}/{max})"
140            ));
141        }
142    }
143
144    let candidate: Option<DateTime<Utc>> = match schedule_type {
145        "cron" => {
146            use std::str::FromStr;
147            match cron::Schedule::from_str(schedule_value) {
148                Ok(schedule) => schedule.upcoming(tz).next().map(|t| t.with_timezone(&Utc)),
149                Err(_) => None,
150            }
151        }
152        "random" => match parse_duration_range(schedule_value) {
153            Ok((min, max)) => Some(sample_random_next(now, min, max, unit)),
154            Err(_) => None,
155        },
156        _ => None,
157    };
158
159    let Some(candidate) = candidate else {
160        return NextRunDecision::Finished(format!(
161            "schedule `{schedule_value}` ({schedule_type}) is no longer computable"
162        ));
163    };
164
165    if let Some(deadline) = not_after {
166        if candidate > deadline {
167            return NextRunDecision::Finished(format!(
168                "next firing {} would pass not_after {}",
169                candidate.to_rfc3339(),
170                deadline.to_rfc3339()
171            ));
172        }
173    }
174
175    NextRunDecision::RunAt(candidate.to_rfc3339())
176}
177
178/// True when the task's deadline has already passed — checked at claim time
179/// so an already-queued task cannot fire after `not_after`.
180pub fn deadline_passed(not_after: Option<&str>, now: DateTime<Utc>) -> bool {
181    not_after
182        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
183        .map(|d| d.with_timezone(&Utc) < now)
184        .unwrap_or(false)
185}
186
187/// Human cadence summary for task listings.
188pub fn describe_schedule(schedule_type: &str, schedule_value: &str) -> String {
189    match schedule_type {
190        "once" => format!("once at {schedule_value}"),
191        "cron" => format!("cron `{schedule_value}`"),
192        "random" => format!("randomly every {schedule_value}"),
193        other => format!("{other} `{schedule_value}`"),
194    }
195}
196
197/// Compact "in 3h 12m" / "overdue" rendering for a future RFC 3339 instant.
198pub fn humanize_until(now: DateTime<Utc>, ts: &str) -> Option<String> {
199    let when = DateTime::parse_from_rfc3339(ts).ok()?.with_timezone(&Utc);
200    let secs = (when - now).num_seconds();
201    if secs <= 0 {
202        return Some("due now".to_string());
203    }
204    let (d, rem) = (secs / 86_400, secs % 86_400);
205    let (h, rem) = (rem / 3600, rem % 3600);
206    let m = rem / 60;
207    Some(match (d, h, m) {
208        (0, 0, m) => format!("in {}m", m.max(1)),
209        (0, h, m) => format!("in {h}h {m}m"),
210        (d, h, _) => format!("in {d}d {h}h"),
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn t(s: &str) -> DateTime<Utc> {
219        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
220    }
221
222    #[test]
223    fn duration_parsing_units_and_errors() {
224        assert_eq!(parse_duration("90s").unwrap().num_seconds(), 90);
225        assert_eq!(parse_duration("30m").unwrap().num_minutes(), 30);
226        assert_eq!(parse_duration("2h").unwrap().num_hours(), 2);
227        assert_eq!(parse_duration("3d").unwrap().num_days(), 3);
228        assert_eq!(parse_duration("1w").unwrap().num_days(), 7);
229        assert_eq!(parse_duration("2mo").unwrap().num_days(), 60);
230        assert!(parse_duration("5x").is_err());
231        assert!(parse_duration("h").is_err());
232        assert!(parse_duration("0m").is_err());
233    }
234
235    #[test]
236    fn range_parsing_accepts_both_separators_and_clamps() {
237        let (min, max) = parse_duration_range("2h..3d").unwrap();
238        assert_eq!(min.num_hours(), 2);
239        assert_eq!(max.num_days(), 3);
240        let (min, _) = parse_duration_range("30m-4h").unwrap();
241        assert_eq!(min.num_minutes(), 30);
242        // Guard rails: hot-loop and multi-year windows rejected.
243        assert!(parse_duration_range("5s..1h").is_err());
244        assert!(parse_duration_range("1d..24mo").is_err());
245        assert!(parse_duration_range("3d..2h").is_err());
246        assert!(parse_duration_range("2h").is_err());
247    }
248
249    #[test]
250    fn random_sampling_stays_inside_window() {
251        let now = t("2026-07-11T00:00:00Z");
252        let (min, max) = parse_duration_range("2h..3d").unwrap();
253        for unit in [0.0, 0.25, 0.5, 0.999] {
254            let next = sample_random_next(now, min, max, unit);
255            assert!(next >= now + Duration::hours(2), "unit={unit}");
256            assert!(next <= now + Duration::days(3), "unit={unit}");
257        }
258        // Degenerate window (min == max) is exact.
259        let next = sample_random_next(now, min, min, 0.7);
260        assert_eq!(next, now + Duration::hours(2));
261    }
262
263    #[test]
264    fn random_unit_is_in_range() {
265        for _ in 0..64 {
266            let u = random_unit();
267            assert!((0.0..1.0).contains(&u));
268        }
269    }
270
271    #[test]
272    fn max_runs_retires_task() {
273        let d = decide_next_run(
274            "random",
275            "2h..3d",
276            chrono_tz::UTC,
277            t("2026-07-11T00:00:00Z"),
278            5,
279            Some(5),
280            None,
281            0.5,
282        );
283        assert!(matches!(d, NextRunDecision::Finished(ref r) if r.contains("max_runs")));
284        // Under the cap: keeps running.
285        let d = decide_next_run(
286            "random",
287            "2h..3d",
288            chrono_tz::UTC,
289            t("2026-07-11T00:00:00Z"),
290            4,
291            Some(5),
292            None,
293            0.5,
294        );
295        assert!(matches!(d, NextRunDecision::RunAt(_)));
296    }
297
298    #[test]
299    fn deadline_retires_task_when_next_firing_passes_it() {
300        let now = t("2026-07-11T00:00:00Z");
301        // Next random firing lands at now+2h..3d; deadline in 1h -> retire.
302        let d = decide_next_run(
303            "random",
304            "2h..3d",
305            chrono_tz::UTC,
306            now,
307            1,
308            None,
309            Some(t("2026-07-11T01:00:00Z")),
310            0.0,
311        );
312        assert!(matches!(d, NextRunDecision::Finished(ref r) if r.contains("not_after")));
313        // Deadline far away -> keeps running.
314        let d = decide_next_run(
315            "random",
316            "2h..3d",
317            chrono_tz::UTC,
318            now,
319            1,
320            None,
321            Some(t("2026-12-31T00:00:00Z")),
322            0.0,
323        );
324        assert!(matches!(d, NextRunDecision::RunAt(_)));
325    }
326
327    #[test]
328    fn cron_still_schedules_and_respects_deadline() {
329        let now = t("2026-07-11T00:00:00Z");
330        let d = decide_next_run(
331            "cron",
332            "0 0 * * * *", // hourly
333            chrono_tz::UTC,
334            now,
335            10,
336            None,
337            None,
338            0.5,
339        );
340        assert!(matches!(d, NextRunDecision::RunAt(_)));
341        let d = decide_next_run(
342            "cron",
343            "0 0 12 1 1 *", // once a year
344            chrono_tz::UTC,
345            now,
346            1,
347            None,
348            Some(t("2026-08-01T00:00:00Z")),
349            0.5,
350        );
351        assert!(matches!(d, NextRunDecision::Finished(_)));
352    }
353
354    #[test]
355    fn invalid_schedule_finishes_instead_of_looping() {
356        let d = decide_next_run(
357            "cron",
358            "not a cron",
359            chrono_tz::UTC,
360            t("2026-07-11T00:00:00Z"),
361            1,
362            None,
363            None,
364            0.5,
365        );
366        assert!(matches!(d, NextRunDecision::Finished(_)));
367    }
368
369    #[test]
370    fn deadline_passed_checks_claim_time() {
371        let now = t("2026-07-11T12:00:00Z");
372        assert!(deadline_passed(Some("2026-07-11T11:00:00Z"), now));
373        assert!(!deadline_passed(Some("2026-07-11T13:00:00Z"), now));
374        assert!(!deadline_passed(None, now));
375        // Unparseable deadline fails open (does not silently kill the task);
376        // creation-time validation is the real gate.
377        assert!(!deadline_passed(Some("garbage"), now));
378    }
379
380    #[test]
381    fn humanize_until_renders_compact() {
382        let now = t("2026-07-11T00:00:00Z");
383        assert_eq!(
384            humanize_until(now, "2026-07-11T00:05:00Z").unwrap(),
385            "in 5m"
386        );
387        assert_eq!(
388            humanize_until(now, "2026-07-11T03:30:00Z").unwrap(),
389            "in 3h 30m"
390        );
391        assert_eq!(
392            humanize_until(now, "2026-07-13T06:00:00Z").unwrap(),
393            "in 2d 6h"
394        );
395        assert_eq!(
396            humanize_until(now, "2026-07-10T00:00:00Z").unwrap(),
397            "due now"
398        );
399    }
400}