rust 109 lines · 3 tabs

Parse Cron Expressions and Run Due Recurring Jobs in Rust with Tokio

Shared by codesnips Aug 2026
3 tabs
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use cron::Schedule as CronSchedule;
use std::str::FromStr;

pub struct Schedule {
    pub name: String,
    cron: CronSchedule,
    next_run: DateTime<Utc>,
}

impl Schedule {
    pub fn new(name: impl Into<String>, expr: &str) -> Result<Self> {
        let cron = CronSchedule::from_str(expr)
            .map_err(|e| anyhow!("invalid cron `{}`: {}", expr, e))?;
        let next_run = Self::compute_next(&cron, Utc::now())?;
        Ok(Self { name: name.into(), cron, next_run })
    }

    fn compute_next(cron: &CronSchedule, after: DateTime<Utc>) -> Result<DateTime<Utc>> {
        cron.after(&after)
            .next()
            .ok_or_else(|| anyhow!("cron schedule has no upcoming occurrences"))
    }

    pub fn is_due(&self, now: DateTime<Utc>) -> bool {
        now >= self.next_run
    }

    pub fn advance(&mut self) -> Result<()> {
        // Anchor to the fired time, not `now`, to avoid drift and skipped ticks.
        self.next_run = Self::compute_next(&self.cron, self.next_run)?;
        Ok(())
    }
}
3 files · rust Explain with highlit

This snippet builds a small recurring-job scheduler in Rust that turns cron expressions into concrete next_run timestamps, stores them per job, and executes only the jobs that are actually due. The pattern separates when a job should fire (schedule computation) from what it does (the task), which keeps the run loop simple and makes each job independently testable.

In schedule.rs, a Schedule wraps a parsed cron::Schedule alongside the job's identity and a cached next_run. The key idea is that a cron expression is not a timer — it is a rule that must be re-evaluated against the current clock. Schedule::new parses the expression once (so a malformed cron fails fast at registration, not mid-loop) and immediately computes the first fire time via compute_next. The is_due check compares next_run against Utc::now, and advance rolls the cached time forward using after, which asks the cron iterator for the first occurrence strictly after a given instant. Anchoring advance to the just-fired next_run rather than to now prevents drift and avoids skipping ticks when the loop wakes up slightly late.

In job.rs, Job is a trait object model: a boxed async closure stored behind JobFn, with name used for logging and correlation. Modeling the work as Arc<dyn Fn -> BoxFuture> lets the scheduler own heterogeneous jobs in one collection and spawn them without knowing their concrete types. The Arc clone per invocation means a slow job never blocks the tick loop, since each due job is handed to tokio::spawn.

In scheduler.rs, Scheduler holds the registered schedules and drives them. register builds a Schedule and pairs it with its Job. The run loop ticks on a fixed interval (a coarse granularity that bounds wakeups regardless of how many jobs exist), scans for due entries, spawns each due job, then calls advance so the same job is not re-run on the next tick. A subtle correctness detail: advance happens right after dispatch, inside the loop that holds the lock, so overlapping ticks cannot double-fire the same occurrence.

The trade-off is resolution — jobs fire on the next interval boundary, not to the millisecond — which is exactly right for cron-style recurring work. For sub-second precision or persistence across restarts, next_run would need to be stored in a database and reloaded, but the same compute_next/advance logic applies unchanged.


Related snips

Share this code

Here's the card — post it anywhere.

Parse Cron Expressions and Run Due Recurring Jobs in Rust with Tokio — share card
Link copied