Skip to main content

cratefield_runtime_native/
cron.rs

1//! In-process cron (issue #19): the native counterpart of a Worker's
2//! `[triggers] crons`. Each expression in `CRONS` gets one tokio task
3//! that sleeps to the next occurrence (UTC) and fans out to every
4//! module's `scheduled(ctx, cron)` — exactly what
5//! `cratefield_runtime_cloudflare::serve_scheduled` does per trigger.
6//!
7//! Expressions are strictly **five-field** (minute hour day month
8//! weekday), the form wrangler accepts; a six-field expression with
9//! leading seconds is rejected rather than silently reinterpreted.
10//! "Now" always comes through the clock (`tokio`/`time`), never
11//! `chrono::Utc::now`: croner's chrono dependency arrives without its
12//! `clock` feature, so that call could not compile here even by
13//! accident (PR #50's lesson, applied structurally).
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use cratefield_core::{Clock, Harness, Ports, ScheduledLimits, ScheduledSplit};
19use time::OffsetDateTime;
20use tokio::task::JoinHandle;
21
22/// A `CRONS` entry that could not be parsed.
23#[derive(Debug, thiserror::Error)]
24#[error("invalid cron expression {expr:?}: {reason}")]
25pub struct CronError {
26    expr: String,
27    reason: String,
28}
29
30fn parse(expr: &str) -> Result<croner::Cron, CronError> {
31    croner::parser::CronParser::builder()
32        .seconds(croner::parser::Seconds::Disallowed)
33        .build()
34        .parse(expr)
35        .map_err(|err| CronError {
36            expr: expr.to_owned(),
37            reason: err.to_string(),
38        })
39}
40
41/// `time` → the datetime library croner computes with (chrono without
42/// its `clock` feature — types only, never `now`).
43fn to_cron_time(t: time::OffsetDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
44    chrono::DateTime::from_timestamp(t.unix_timestamp(), t.nanosecond().rem_euclid(1_000_000_000))
45}
46
47/// One task per expression: sleep to the next occurrence, fan out,
48/// repeat. Ticks are missed, not queued, while the fan-out runs — the
49/// same posture a Worker has when a scheduled handler overruns its
50/// schedule.
51async fn run_expression(harness: Arc<Harness>, ports: Ports, expr: String, cron: croner::Cron) {
52    loop {
53        let now = to_cron_time(crate::ports::TokioClock.now());
54        let Some(now) = now else {
55            tracing::error!(cron = %expr, "clock out of range; cron task stopping");
56            return;
57        };
58        let next = match cron.find_next_occurrence(&now, false) {
59            Ok(next) => next,
60            Err(err) => {
61                tracing::error!(cron = %expr, error = %err, "cannot compute next tick; stopping");
62                return;
63            }
64        };
65        let until_next = (next - now)
66            .to_std()
67            .unwrap_or_else(|_| Duration::from_millis(1));
68        tokio::time::sleep(until_next).await;
69        fan_out(&harness, &ports, &expr).await;
70    }
71}
72
73/// Fans one trigger out to every module's `scheduled(ctx, cron)` with a
74/// per-tick context. Handler errors are logged and never fail the tick
75/// (or the process) — the semantics of `serve_scheduled` on Workers.
76/// Every module gets an **unbounded** budget (`ctx.scheduled` is the same
77/// contract here it is on Workers, it just never runs out: the native
78/// runtime has no Workers invocation allowance to spend against). Public
79/// so a venture that owns its scheduler (or its tests) can drive the same
80/// fan-out itself.
81pub async fn fan_out(harness: &Harness, ports: &Ports, cron: &str) {
82    // The clock and the epoch fallback are never read for real: an
83    // unbounded split computes no deadlines.
84    let clock = ports.clock.clone();
85    let mut split = ScheduledSplit::new(
86        ScheduledLimits::UNBOUNDED,
87        clock
88            .as_ref()
89            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now()),
90        harness.modules().len(),
91    );
92    for module in harness.modules() {
93        let now = clock
94            .as_ref()
95            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
96        let budget = Arc::new(split.next(now));
97        let mut module_ctx = harness.module_context(module.as_ref(), ports);
98        module_ctx.scheduled = Arc::clone(&budget);
99        if let Err(err) = module.scheduled(&module_ctx, cron).await {
100            tracing::error!(
101                module = module.name(),
102                cron = %cron,
103                error = %err,
104                "scheduled module work failed",
105            );
106        }
107        split.settle(&budget);
108    }
109}
110
111/// Validates every expression, then spawns one task per expression.
112/// Validation happens before any task starts so an invalid `CRONS`
113/// entry fails the process at startup (wrangler's deploy-time check),
114/// never as a silently dead schedule.
115///
116/// # Errors
117///
118/// [`CronError`] naming the first expression that does not parse as a
119/// five-field cron.
120pub fn spawn_cron_scheduler(
121    harness: &Arc<Harness>,
122    ports: &Ports,
123    expressions: &[String],
124) -> Result<Vec<JoinHandle<()>>, CronError> {
125    let parsed: Vec<(String, croner::Cron)> = expressions
126        .iter()
127        .map(|expr| parse(expr).map(|cron| (expr.clone(), cron)))
128        .collect::<Result<_, _>>()?;
129    Ok(parsed
130        .into_iter()
131        .map(|(expr, cron)| {
132            tokio::spawn(run_expression(
133                Arc::clone(harness),
134                crate::runtime::clone_ports(ports),
135                expr,
136                cron,
137            ))
138        })
139        .collect())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn five_field_expressions_parse() {
148        for expr in ["*/5 * * * *", "0 3 * * *", "30 4 1,15 8 *"] {
149            parse(expr).unwrap_or_else(|err| panic!("{expr} should parse: {err}"));
150        }
151    }
152
153    #[test]
154    fn six_field_expressions_are_rejected_not_reinterpreted() {
155        let err = parse("0 */5 * * * *").expect_err("leading seconds must be rejected");
156        assert!(err.to_string().contains("0 */5 * * * *"));
157    }
158
159    #[test]
160    fn plain_garbage_is_rejected() {
161        assert!(parse("not a cron").is_err());
162        assert!(parse("").is_err());
163    }
164}