use std::sync::Arc;
use std::time::Duration;
use cratefield_core::{Clock, Harness, Ports, ScheduledLimits, ScheduledSplit};
use time::OffsetDateTime;
use tokio::task::JoinHandle;
#[derive(Debug, thiserror::Error)]
#[error("invalid cron expression {expr:?}: {reason}")]
pub struct CronError {
expr: String,
reason: String,
}
fn parse(expr: &str) -> Result<croner::Cron, CronError> {
croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Disallowed)
.build()
.parse(expr)
.map_err(|err| CronError {
expr: expr.to_owned(),
reason: err.to_string(),
})
}
fn to_cron_time(t: time::OffsetDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::from_timestamp(t.unix_timestamp(), t.nanosecond().rem_euclid(1_000_000_000))
}
async fn run_expression(harness: Arc<Harness>, ports: Ports, expr: String, cron: croner::Cron) {
loop {
let now = to_cron_time(crate::ports::TokioClock.now());
let Some(now) = now else {
tracing::error!(cron = %expr, "clock out of range; cron task stopping");
return;
};
let next = match cron.find_next_occurrence(&now, false) {
Ok(next) => next,
Err(err) => {
tracing::error!(cron = %expr, error = %err, "cannot compute next tick; stopping");
return;
}
};
let until_next = (next - now)
.to_std()
.unwrap_or_else(|_| Duration::from_millis(1));
tokio::time::sleep(until_next).await;
fan_out(&harness, &ports, &expr).await;
}
}
pub async fn fan_out(harness: &Harness, ports: &Ports, cron: &str) {
let clock = ports.clock.clone();
let mut split = ScheduledSplit::new(
ScheduledLimits::UNBOUNDED,
clock
.as_ref()
.map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now()),
harness.modules().len(),
);
for module in harness.modules() {
let now = clock
.as_ref()
.map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
let budget = Arc::new(split.next(now));
let mut module_ctx = harness.module_context(module.as_ref(), ports);
module_ctx.scheduled = Arc::clone(&budget);
if let Err(err) = module.scheduled(&module_ctx, cron).await {
tracing::error!(
module = module.name(),
cron = %cron,
error = %err,
"scheduled module work failed",
);
}
split.settle(&budget);
}
}
pub fn spawn_cron_scheduler(
harness: &Arc<Harness>,
ports: &Ports,
expressions: &[String],
) -> Result<Vec<JoinHandle<()>>, CronError> {
let parsed: Vec<(String, croner::Cron)> = expressions
.iter()
.map(|expr| parse(expr).map(|cron| (expr.clone(), cron)))
.collect::<Result<_, _>>()?;
Ok(parsed
.into_iter()
.map(|(expr, cron)| {
tokio::spawn(run_expression(
Arc::clone(harness),
crate::runtime::clone_ports(ports),
expr,
cron,
))
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn five_field_expressions_parse() {
for expr in ["*/5 * * * *", "0 3 * * *", "30 4 1,15 8 *"] {
parse(expr).unwrap_or_else(|err| panic!("{expr} should parse: {err}"));
}
}
#[test]
fn six_field_expressions_are_rejected_not_reinterpreted() {
let err = parse("0 */5 * * * *").expect_err("leading seconds must be rejected");
assert!(err.to_string().contains("0 */5 * * * *"));
}
#[test]
fn plain_garbage_is_rejected() {
assert!(parse("not a cron").is_err());
assert!(parse("").is_err());
}
}