cratefield_runtime_native/
cron.rs1use 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#[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
41fn 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
47async 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
73pub async fn fan_out(harness: &Harness, ports: &Ports, cron: &str) {
82 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
111pub 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}