use crate::cron::{Cron, Weekday};
use crate::job::{BoxFuture, Job, QueuedJob};
use crate::queue::Queue;
use crate::time::{self, unix_now};
use crate::worker::Shutdown;
use rustlavel_core::{Error, Result};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
pub fn schedule(queue: Arc<dyn Queue>) -> Scheduler {
Scheduler::new(queue)
}
type Task = Arc<dyn Fn() -> BoxFuture<'static, Result<()>> + Send + Sync>;
#[derive(Clone)]
enum Action {
Dispatch(QueuedJob),
Call(Task),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Fields {
minute: String,
hour: String,
day_of_month: String,
month: String,
day_of_week: String,
}
impl Fields {
fn every_minute() -> Self {
Fields {
minute: "*".into(),
hour: "*".into(),
day_of_month: "*".into(),
month: "*".into(),
day_of_week: "*".into(),
}
}
fn expression(&self) -> String {
format!(
"{} {} {} {} {}",
self.minute, self.hour, self.day_of_month, self.month, self.day_of_week
)
}
}
pub struct ScheduledEvent {
name: String,
fields: Fields,
action: Action,
without_overlapping: bool,
running: Arc<AtomicBool>,
error: Option<String>,
}
impl ScheduledEvent {
fn new(name: String, action: Action) -> Self {
ScheduledEvent {
name,
fields: Fields::every_minute(),
action,
without_overlapping: false,
running: Arc::new(AtomicBool::new(false)),
error: None,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn expression(&self) -> String {
self.fields.expression()
}
pub fn cron(&mut self, expression: &str) -> &mut Self {
let parts: Vec<&str> = expression.split_whitespace().collect();
if parts.len() != 5 {
return self.complain(format!(
"`{expression}` has {} field(s); a cron expression needs exactly 5",
parts.len()
));
}
self.fields = Fields {
minute: parts[0].into(),
hour: parts[1].into(),
day_of_month: parts[2].into(),
month: parts[3].into(),
day_of_week: parts[4].into(),
};
self
}
pub fn every_minute(&mut self) -> &mut Self {
self.fields = Fields::every_minute();
self
}
pub fn every_minutes(&mut self, n: u32) -> &mut Self {
if n == 0 || n > 59 {
return self.complain(format!(
"every_minutes({n}) is not a schedule: use a value between 1 and 59"
));
}
self.fields.minute = format!("*/{n}");
self
}
pub fn hourly(&mut self) -> &mut Self {
self.fields.minute = "0".into();
self.fields.hour = "*".into();
self
}
pub fn daily(&mut self) -> &mut Self {
self.fields.minute = "0".into();
self.fields.hour = "0".into();
self
}
pub fn weekly_on(&mut self, day: Weekday, time: &str) -> &mut Self {
self.fields.day_of_week = day.number().to_string();
self.at(time)
}
pub fn monthly(&mut self) -> &mut Self {
self.daily();
self.fields.day_of_month = "1".into();
self
}
pub fn at(&mut self, time: &str) -> &mut Self {
let Some((hour, minute)) = time.split_once(':') else {
return self.complain(format!("`{time}` is not a time. Write it as `HH:MM`."));
};
match (hour.parse::<u32>(), minute.parse::<u32>()) {
(Ok(hour), Ok(minute)) if hour < 24 && minute < 60 => {
self.fields.hour = hour.to_string();
self.fields.minute = minute.to_string();
self
}
_ => self.complain(format!(
"`{time}` is not a time. Write it as `HH:MM`, with the hour between 00 and 23."
)),
}
}
pub fn without_overlapping(&mut self) -> &mut Self {
self.without_overlapping = true;
self
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
fn complain(&mut self, message: String) -> &mut Self {
if self.error.is_none() {
self.error = Some(message);
}
self
}
}
struct CompiledEvent {
name: String,
cron: Cron,
action: Action,
without_overlapping: bool,
running: Arc<AtomicBool>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TickReport {
pub started: Vec<String>,
pub skipped: Vec<String>,
}
pub struct Scheduler {
queue: Arc<dyn Queue>,
events: Vec<ScheduledEvent>,
}
impl Scheduler {
pub fn new(queue: Arc<dyn Queue>) -> Self {
Scheduler { queue, events: Vec::new() }
}
pub fn job<J: Job>(&mut self, job: J) -> &mut ScheduledEvent {
self.add(ScheduledEvent::new(J::NAME.to_string(), Action::Dispatch(job.to_queued())))
}
pub fn push(&mut self, job: QueuedJob) -> &mut ScheduledEvent {
self.add(ScheduledEvent::new(job.name.clone(), Action::Dispatch(job)))
}
pub fn call<F>(&mut self, name: impl Into<String>, task: F) -> &mut ScheduledEvent
where
F: Fn() -> BoxFuture<'static, Result<()>> + Send + Sync + 'static,
{
self.add(ScheduledEvent::new(name.into(), Action::Call(Arc::new(task))))
}
fn add(&mut self, event: ScheduledEvent) -> &mut ScheduledEvent {
self.events.push(event);
self.events.last_mut().expect("just pushed")
}
pub fn events(&self) -> &[ScheduledEvent] {
&self.events
}
fn compile(&self) -> Result<Vec<CompiledEvent>> {
let mut compiled = Vec::with_capacity(self.events.len());
let mut problems = Vec::new();
for event in &self.events {
if let Some(error) = &event.error {
problems.push(format!("`{}`: {error}", event.name));
continue;
}
match Cron::parse(&event.expression()) {
Ok(cron) => compiled.push(CompiledEvent {
name: event.name.clone(),
cron,
action: event.action.clone(),
without_overlapping: event.without_overlapping,
running: Arc::clone(&event.running),
}),
Err(error) => problems.push(format!("`{}`: {error}", event.name)),
}
}
if problems.is_empty() {
Ok(compiled)
} else {
Err(Error::msg(format!("this schedule cannot run:\n {}", problems.join("\n "))))
}
}
pub fn validate(&self) -> Result<()> {
self.compile().map(|_| ())
}
pub fn due_at(&self, at: i64) -> Result<Vec<String>> {
let moment = time::from_unix(at);
Ok(self
.compile()?
.into_iter()
.filter(|event| event.cron.matches(&moment))
.map(|event| event.name)
.collect())
}
pub fn next_runs_after(&self, at: i64) -> Result<Vec<(String, Option<i64>)>> {
Ok(self
.compile()?
.into_iter()
.map(|event| (event.name, event.cron.next_after(at)))
.collect())
}
pub async fn tick(&self, at: i64) -> Result<TickReport> {
let moment = time::from_unix(at);
let mut report = TickReport::default();
for event in self.compile()? {
if !event.cron.matches(&moment) {
continue;
}
let claimed = event
.running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok();
if !claimed && event.without_overlapping {
rustlavel_core::warn!(
"scheduled task `{}` is still running; skipping this run",
event.name
);
report.skipped.push(event.name);
continue;
}
report.started.push(event.name.clone());
let queue = Arc::clone(&self.queue);
let guard = RunningGuard(Arc::clone(&event.running));
let name = event.name.clone();
let action = event.action.clone();
tokio::spawn(async move {
let _guard = guard;
let result = match action {
Action::Dispatch(job) => queue.push(job).await.map(|_| ()),
Action::Call(task) => task().await,
};
if let Err(error) = result {
rustlavel_core::error!("scheduled task `{name}` failed: {error}");
}
});
}
Ok(report)
}
pub async fn run(&self, shutdown: Shutdown) -> Result<()> {
self.compile()?;
let mut last_tick: Option<i64> = None;
loop {
if shutdown.is_signalled() {
return Ok(());
}
let now = unix_now();
let minute = time::start_of_minute(now);
if last_tick != Some(minute) {
last_tick = Some(minute);
self.tick(minute).await?;
}
let next = time::start_of_minute(unix_now()) + 60;
let wait = Duration::from_millis(((next - unix_now()).max(0) as u64) * 1000 + 50);
if shutdown.wait_for(wait).await {
return Ok(());
}
}
}
}
struct RunningGuard(Arc<AtomicBool>);
impl Drop for RunningGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::MemoryQueue;
use crate::tests_support::{CountingJob, counter};
use crate::time::to_unix;
fn scheduler() -> (Arc<MemoryQueue>, Scheduler) {
let queue = Arc::new(MemoryQueue::new());
let scheduler = Scheduler::new(queue.clone());
(queue, scheduler)
}
#[test]
fn the_builder_methods_produce_the_expressions_they_promise() {
let (_queue, mut schedule) = scheduler();
let tally = counter();
assert_eq!(schedule.job(CountingJob::new(tally, 1)).every_minute().expression(), "* * * * *");
assert_eq!(schedule.job(CountingJob::new(tally, 1)).hourly().expression(), "0 * * * *");
assert_eq!(schedule.job(CountingJob::new(tally, 1)).daily().expression(), "0 0 * * *");
assert_eq!(
schedule.job(CountingJob::new(tally, 1)).daily().at("13:00").expression(),
"0 13 * * *"
);
assert_eq!(
schedule.job(CountingJob::new(tally, 1)).every_minutes(5).expression(),
"*/5 * * * *"
);
assert_eq!(
schedule
.job(CountingJob::new(tally, 1))
.weekly_on(Weekday::Monday, "09:30")
.expression(),
"30 9 * * 1"
);
assert_eq!(schedule.job(CountingJob::new(tally, 1)).monthly().expression(), "0 0 1 * *");
assert_eq!(
schedule.job(CountingJob::new(tally, 1)).cron("*/5 * * * *").expression(),
"*/5 * * * *"
);
schedule.validate().unwrap();
}
#[test]
fn a_bad_time_is_reported_when_the_schedule_is_checked_not_when_it_is_written() {
let (_queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).daily().at("25:00");
let error = schedule.validate().unwrap_err().to_string();
assert!(error.contains("`counting`"), "{error}");
assert!(error.contains("`25:00` is not a time"), "{error}");
}
#[test]
fn every_bad_event_is_reported_at_once() {
let (_queue, mut schedule) = scheduler();
schedule.call("first", || Box::pin(async { Ok(()) })).at("nonsense");
schedule.call("second", || Box::pin(async { Ok(()) })).cron("not a cron expression");
schedule.call("third", || Box::pin(async { Ok(()) })).every_minutes(0);
let error = schedule.validate().unwrap_err().to_string();
assert!(error.contains("`first`"), "{error}");
assert!(error.contains("`second`"), "{error}");
assert!(error.contains("`third`"), "{error}");
}
#[test]
fn a_schedule_reports_what_is_due_and_what_comes_next() {
let (_queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).daily().at("13:00");
let one_pm = to_unix(2026, 8, 29, 13, 0, 0);
assert_eq!(schedule.due_at(one_pm).unwrap(), vec!["counting".to_string()]);
assert!(schedule.due_at(one_pm + 60).unwrap().is_empty());
let next = schedule.next_runs_after(to_unix(2026, 8, 29, 14, 0, 0)).unwrap();
assert_eq!(next[0].1, Some(to_unix(2026, 8, 30, 13, 0, 0)));
}
#[tokio::test]
async fn a_due_job_is_dispatched_onto_the_queue_rather_than_run() {
let (queue, mut schedule) = scheduler();
let tally = counter();
schedule.job(CountingJob::new(tally, 1)).daily().at("13:00");
let report = schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
assert_eq!(report.started, vec!["counting".to_string()]);
for _ in 0..100 {
if queue.size("default").await.unwrap() == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(queue.size("default").await.unwrap(), 1);
assert_eq!(tally.runs(), 0, "the scheduler queues work, it does not do it");
}
#[tokio::test]
async fn a_job_that_is_not_due_is_left_alone() {
let (queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).daily().at("13:00");
let report = schedule.tick(to_unix(2026, 8, 29, 9, 0, 0)).await.unwrap();
assert!(report.started.is_empty());
assert_eq!(queue.size("default").await.unwrap(), 0);
}
#[tokio::test]
async fn without_overlapping_skips_a_run_while_the_last_one_is_still_going() {
let (_queue, mut schedule) = scheduler();
let tally = counter();
schedule
.call("slow-task", move || {
Box::pin(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
tally.record(1);
Ok(())
})
})
.every_minute()
.without_overlapping();
let first = schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
assert_eq!(first.started, vec!["slow-task".to_string()]);
tokio::time::sleep(Duration::from_millis(20)).await;
let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
assert!(second.started.is_empty());
assert_eq!(second.skipped, vec!["slow-task".to_string()]);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(tally.runs(), 1);
let third = schedule.tick(to_unix(2026, 8, 29, 13, 2, 0)).await.unwrap();
assert_eq!(third.started, vec!["slow-task".to_string()]);
}
#[tokio::test]
async fn without_it_a_slow_task_is_allowed_to_overlap() {
let (_queue, mut schedule) = scheduler();
let tally = counter();
schedule.call("slow-task", move || {
Box::pin(async move {
tokio::time::sleep(Duration::from_millis(150)).await;
tally.record(1);
Ok(())
})
});
schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
assert_eq!(second.started, vec!["slow-task".to_string()], "the second run started too");
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(tally.runs(), 2, "both runs completed");
}
#[tokio::test]
async fn a_panicking_task_releases_the_overlap_lock() {
let (_queue, mut schedule) = scheduler();
schedule
.call("panicking-task", || {
Box::pin(async {
tokio::task::yield_now().await;
panic!("scheduled work can panic too");
})
})
.every_minute()
.without_overlapping();
schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
assert_eq!(
second.started,
vec!["panicking-task".to_string()],
"a task that blew up must not hold the lock forever"
);
}
#[tokio::test]
async fn run_refuses_to_start_with_a_broken_expression() {
let (_queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).cron("* * *");
let shutdown = Shutdown::new();
shutdown.signal();
assert!(schedule.run(shutdown).await.is_err());
}
#[tokio::test]
async fn run_stops_on_shutdown() {
let (_queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).daily().at("03:00");
let shutdown = Shutdown::new();
let signal = shutdown.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
signal.signal();
});
let started = std::time::Instant::now();
schedule.run(shutdown).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(30), "it should not wait out a full minute");
}
#[test]
fn an_event_knows_its_own_name() {
let (_queue, mut schedule) = scheduler();
schedule.job(CountingJob::new(counter(), 1)).daily();
assert_eq!(schedule.events()[0].name(), "counting");
assert!(!schedule.events()[0].is_running());
}
}