Cron jobs
Run local jobs on an interval, built from the ServiceBuilder, with three ways to space the runs and a schedule that keeps going through errors and panics.
Most "cron" work in a microservice is a scan: expire stale sessions, retry stuck work, reconcile a search index. A CronJob is exactly that: a Processor of CronTicks with a constant INTERVAL, a CronJobInterval that says how its runs are spaced. The interval is a constant of the job type, not a calendar expression: there is no "every day at 03:00" syntax.
If you come from Spring, the closest thing is @Scheduled on a bean method, and the three kinds of interval map onto its options: AfterFinish is fixedDelay, AtLeast is fixedRate without overlapping runs, and AfterStart is fixedRate with @Async. One difference is covered below: every replica of the service runs every job.
Defining a job
A job is a struct holding its dependencies. It implements three traits:
ServiceCreation, to be built from a value registered in the service builder;Processor<CronTick>withOutput = ()and anErrorthat implementsDisplay, which is one run;CronJob, which adds theINTERVAL.
The job must also be Send + Sync + 'static, because it runs on its own Tokio task.
use std::time::Duration;
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::{CronJob, CronJobInterval, CronTick, ServiceCreation};
pub struct ExpireSessions {
store: SessionStore,
}
impl ServiceCreation for ExpireSessions {
type Dep = SessionStore;
fn create_service(store: SessionStore) -> Self {
Self { store }
}
}
impl Processor<CronTick> for ExpireSessions {
type Output = ();
type Error = StoreError;
async fn process(&self, _tick: CronTick) -> Result<(), StoreError> {
let deleted = self.store.delete_expired(1_000).await?;
tracing::info!(deleted, "expired sessions removed");
Ok(())
}
}
impl CronJob for ExpireSessions {
const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::from_secs(60));
}SessionStore and StoreError stand in for your own data access. The store is cheap to clone (it is cloned out of the service builder when the job is built), and the error only needs Display, because that is how a failed run is logged:
/// Stand-in for a database pool: cheap to clone, every clone shares the data.
#[derive(Clone, Default)]
pub struct SessionStore {
expired: Arc<AtomicU64>,
}
impl SessionStore {
pub async fn delete_expired(&self, limit: u64) -> Result<u64, StoreError> {
self.expired.fetch_add(1, Ordering::Relaxed);
Ok(limit.min(1))
}
}
#[derive(Debug)]
pub struct StoreError(String);
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "session store: {}", self.0)
}
}If you use the data layer, a Db handle is a natural Dep, and its query errors already implement Display; see Data access.
The tick
Each run receives a CronTick:
| Field | Type | Meaning |
|---|---|---|
run | u64 | Number of earlier runs of this job since its runtime started: 0 for the first run. |
fired_at | SystemTime | Wall-clock time when the run started. |
run counts runs that actually happened. How far apart they are depends on the interval kind and on how long runs take, and AfterStart does not count grid points it had to skip, so run is not "time since start divided by the interval". It is good for things like "do a full pass every Nth run":
impl Processor<CronTick> for ReindexSearch {
type Output = ();
type Error = StoreError;
async fn process(&self, tick: CronTick) -> Result<(), StoreError> {
// Every 24th run (and the first one) rebuilds everything;
// the others only pick up changes.
let reindexed = if tick.run % 24 == 0 {
self.index.reindex_all().await?
} else {
self.index.reindex_changed().await?
};
tracing::info!(run = tick.run, fired_at = ?tick.fired_at, reindexed, "search index refreshed");
Ok(())
}
}
impl CronJob for ReindexSearch {
const INTERVAL: CronJobInterval = CronJobInterval::AfterFinish(Duration::from_secs(60 * 60));
}Registering and starting
ServiceBuilder::cron_job::<Job, Position>() looks up the job's Dep, clones it, builds the job with create_service and starts a job list. Chain one call per job, then start:
let (services, cron) = ServiceBuilder::new(SessionStore::default())
.push(SearchIndex::default())
.cron_job::<ExpireSessions, _>()
.cron_job::<ReindexSearch, _>()
.start();
// `services` is handed back: register gRPC servers, consumers, ... from it.
let _store: &SessionStore = services.provide::<SessionStore, _>();
let _ = tokio::signal::ctrl_c().await;
// Lets the runs in progress finish, then stops every job.
cron.shutdown().await;Positionis_unless theDeptype is registered more than once (then useHere/There, see The service builder). ADepthat was never registered is a compile error.- A job that needs several registered values is registered with
compose_cron_jobinstead; see Jobs built from several services. startspawns one Tokio task per job and returns the service builder together with aCronRuntime. It must be called inside a Tokio runtime; outside one it panics.- Jobs are stored in a type-level list, like gRPC servers and the service builder itself: no boxing, no dynamic dispatch.
The schedule
Every job's first run is at start, the moment start was called, not one interval later. After that, the variant of INTERVAL decides when the next run starts, for its duration d:
INTERVAL | The next run starts | Runs of the job overlap | Spring |
|---|---|---|---|
AfterFinish(d) | d after the previous run finished | never | fixedDelay |
AtLeast(d) | d after the previous run started, or as soon as it finished if that is later | never | fixedRate, without overlap |
AfterStart(d) | on the grid start + k × d, whatever the previous run is doing | yes | fixedRate with @Async |
The three only differ once a run takes a while. In the diagram, the first run takes 2.5·d and every later run 0.25·d:
INTERVAL | Runs start at | |
|---|---|---|
AfterFinish(d) | 0, 3.5·d, 4.75·d, 6·d, … | Always a pause of d between runs, so starts are d + run time apart. |
AtLeast(d) | 0, 2.5·d, 3.5·d, 4.5·d, … | The overrun is followed at once, then the job is back to one run per d. |
AfterStart(d) | 0, d, 2·d, 3·d, … | The runs at d and 2·d start while the first is still going. |
Different jobs are independent: a slow ReindexSearch never delays ExpireSessions.
Choosing an interval
AtLeast(d): at most one run per d, never two at once. This is the usual choice for a scan that should run "about every d". A run that overruns delays the next one instead of piling up a backlog: the next run starts as soon as it finished, then the job is back on its pace. Consecutive starts are at least d apart on the monotonic clock, so their fired_at are too, unless the wall clock is adjusted in between.
AfterFinish(d): a guaranteed pause between runs. The time between two starts is d plus the run time, so the schedule drifts by every run's duration. What you get in exchange is rest for whatever the job puts load on: a database or a rate-limited API always gets d of quiet after a run, however long that run was. Choose it for work that drains a backlog in batches, or whose run time grows with load.
AfterStart(d): a fixed grid, runs may overlap. Every grid point starts a run in its own task, whether the previous run has finished or not. Choose it when timing matters more than exclusivity, such as sampling or a heartbeat that must not slip because one run was slow. Keep in mind:
- The job runs concurrently with itself.
processtakes&self, so any state it shares between runs needs synchronization, and the work itself must tolerate two runs at once. - If runs keep taking longer than
d, they keep accumulating: nothing bounds how many run at once. - Grid points missed because the runtime was blocked (for example by blocking code on a worker thread) are skipped, not run in a burst, and
rundoes not count them. fired_atincludes the wake-up delay, so two consecutivefired_atcan be slightly less thandapart. That matters for fences across replicas.
Errors and panics
A run that returns Err is logged with tracing at error level (cron job failed, with the error's Display output). A run that panics is caught and logged as well (cron job panicked, with the panic message). In both cases the job keeps its schedule: the next run starts when its interval says, as if the run had succeeded.
Every run executes inside a cron_job tracing span with the fields job (the job's type name) and run, so everything your job logs can be traced back to the run. Install a tracing subscriber in main to see these logs.
panic = "abort"
Catching a panic relies on unwinding. If your release profile sets panic = "abort", a panicking job takes the whole process down instead.
A zero interval does not build
A duration of zero, in any of the three variants, has no schedule, so registering such a job is rejected at compile time. Here Busy implements ServiceCreation and Processor<CronTick> as usual:
impl CronJob for Busy {
const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::ZERO); // no schedule
}
#[tokio::main]
async fn main() {
let (_services, cron) = ServiceBuilder::new(Config)
.cron_job::<Busy, _>() // `cargo build` fails: INTERVAL is zero
.start();
cron.shutdown().await;
}cargo build stops with (abridged):
error[E0080]: evaluation panicked: CronJob::INTERVAL must not be zero
--> wakuwaku/src/services/cron_job.rs:300:17
|
300 | const { assert_interval::<Job>() };
| ^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `wakuwaku::services::cron_job::CronRegisterCenter::<Busy>::new::{constant#0}` failed inside this call
...
note: the above error was encountered while instantiating `fn CronRegisterCenter::<Busy>::new`The error points into wakuwaku's source rather than at your cron_job call; the job type in CronRegisterCenter::<Busy> tells you which job to fix.
cargo check does not see it
The check is a const assertion that is evaluated when the registration is instantiated during code generation. cargo build and cargo run report it. cargo check, cargo clippy and your editor do not, and neither does cargo test unless a test actually reaches the registration (the test harness never calls your main, so the registrations in it are not instantiated). If CI only runs check, clippy and tests, add a cargo build step.
Running on several replicas
Jobs run locally: every process that starts a job runs it. With three replicas of a service, each run happens three times, and because each replica's schedule starts when that replica started, the three runs are not aligned in time.
Design each job for that. In order of preference:
- Make it idempotent. "Delete sessions that expired" is safe to run three times; the second and third runs find nothing to do.
- Partition the work. Have each replica scan only its share of the keys (for example by a hash of the id modulo the replica count).
- Take a lock inside the run, such as the Redis
Lockfrom theredisfeature (see Redis and Locks).
With a lock, every replica tries to take it and at most one succeeds; the others skip that run:
use std::time::Duration;
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::redis::RedisError;
use wakuwaku::entity::redis::RedisSource;
use wakuwaku::entity::redis::collection::lock::Lock;
use wakuwaku::entity::{Db, ReadWrite};
use wakuwaku::{CronJob, CronJobInterval, CronTick, ServiceCreation};
/// One lock per job name. The lease must outlast a run.
const CRON_LOCKS: Lock<str> = Lock::new("cron", Duration::from_secs(5 * 60));
pub struct SendDailyDigest {
db: Db<RedisSource, ReadWrite>,
}
impl ServiceCreation for SendDailyDigest {
type Dep = Db<RedisSource, ReadWrite>;
fn create_service(db: Db<RedisSource, ReadWrite>) -> Self {
Self { db }
}
}
impl Processor<CronTick> for SendDailyDigest {
type Output = ();
type Error = RedisError;
async fn process(&self, _tick: CronTick) -> Result<(), RedisError> {
// Every replica tries; at most one gets the token.
let Some(token) = self.db.process(CRON_LOCKS.try_lock("send-daily-digest")).await? else {
return Ok(()); // Another replica is running it right now.
};
// ... build and send the digest; call `token.extend()` for long work ...
self.db.process(token.release()).await?;
Ok(())
}
}
impl CronJob for SendDailyDigest {
const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::from_secs(60 * 60));
}Acquiring never waits: try_lock outputs Some(token) if the lock was free and None otherwise. The lease (the lock's TTL) is what frees the lock if the holder crashes, so work that may outlive it must call token.extend().
A lock prevents overlap, not repetition
Releasing the lock at the end of the run only guarantees that two replicas never run the job at the same time. Because the replicas' schedules are offset, another replica may take the free lock a few seconds later and run the job again in the same interval.
Once per interval across replicas
To run each pass about once per d across all replicas, let a run claim the pass in shared storage with a fence on fired_at: the run goes ahead only if it can atomically record its own fired_at and the last recorded one is at least d earlier. In SQL, with one row per job:
-- $1 = job name, $2 = the tick's fired_at.
-- The run goes ahead only if this changes a row
-- (seed each job's row with an old last_fired_at).
UPDATE cron_claims
SET last_fired_at = $2
WHERE job = $1
AND last_fired_at <= $2 - INTERVAL '1 hour';The Redis lock can play the same role: do not release it, and set its lease a little shorter than d, so the lock itself records "someone ran this recently". The work must then finish well within the lease.
Use AfterFinish or AtLeast for a fenced job. Their consecutive fired_at on one replica are at least d apart, so a replica never fences out its own next run. AfterStart runs can fire slightly less than d apart, and the fence would then reject a run that should have gone ahead, skipping a whole pass.
Jobs built from several services
cron_job builds a job from one registered value, its Dep. A job that needs several registered values, or that you build by hand, is registered with compose_cron_job: the closure borrows the service builder, provides whatever it needs, and returns the job (see Composing components with a closure). The job needs no ServiceCreation impl and is not registered as a service. cron_job and compose_cron_job chain freely:
/// Needs two registered values, so it is composed.
pub struct ReconcileIndex {
store: SessionStore,
index: SearchIndex,
}let (services, cron) = ServiceBuilder::new(SessionStore::default())
.push(SearchIndex::default())
.cron_job::<ExpireSessions, _>()
.compose_cron_job(|s| ReconcileIndex {
store: s.provide::<SessionStore, _>().clone(),
index: s.provide::<SearchIndex, _>().clone(),
})
.start();A composed job still implements Processor<CronTick> and CronJob; it only skips ServiceCreation. To start the jobs later, end the chain with into_parts instead of start: it returns the service builder and the unstarted CronRegisterCenter. CronRegisterCenter also works without a service builder at all:
use wakuwaku::services::cron_job::CronRegisterCenter;
let store = SessionStore::default();
let cron = CronRegisterCenter::new(ExpireSessions::create_service(store.clone()))
.push(ReconcileIndex { store, index: SearchIndex::default() })
.start();
cron.shutdown().await;new and push both apply the zero-interval check.
Shutting down
CronRuntime gives you three ways to stop the jobs:
| What happens | |
|---|---|
cron.shutdown().await | Graceful. No new runs start, and jobs waiting for their next run stop at once. Runs in progress, including every overlapping AfterStart run, are allowed to finish. Returns when every job has stopped. |
cron.abort().await | Runs in progress are cancelled at their next .await, possibly midway through their side effects. Returns when every job has stopped. |
dropping cron | Like abort, without waiting. |
Prefer shutdown, so a job is never cut off in the middle of a batch. shutdown waits as long as the runs take, so a run that hangs would block it forever; bound it with a timeout if that matters:
if tokio::time::timeout(Duration::from_secs(30), cron.shutdown()).await.is_err() {
// Timing out drops the `CronRuntime`, which aborts the stuck runs.
tracing::warn!("cron jobs did not stop within 30s; aborted");
}In a service that also serves gRPC, start the jobs, build the servers from the service builder that start hands back, and stop the jobs after the server has drained:
let (services, cron) = ServiceBuilder::new(AccountStore::default())
.push(SessionStore::default())
.cron_job::<ExpireSessions, _>()
.start();
let (_services, register) = services
.grpc_service::<AccountsServer<_>, AccountsService, _>(AccountsServer::new)
.into_router_fn();
let router = Server::builder().add_routes(Routes::default());
register(router)
.serve_with_shutdown("[::1]:50051".parse()?, async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
// The server has drained; now let the jobs finish their current run.
cron.shutdown().await;See gRPC services for the server side and Lifecycle for the order across a whole service.
Testing a job
A job is a plain value, so a unit test builds it and calls process with a CronTick made by hand. No runtime, no waiting for the interval:
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::*;
#[tokio::test]
async fn expire_sessions_deletes_from_the_store() {
let store = SessionStore::default();
let job = ExpireSessions::create_service(store.clone());
let tick = CronTick { run: 0, fired_at: SystemTime::now() };
job.process(tick).await.unwrap();
assert_eq!(store.expired.load(Ordering::Relaxed), 1);
}
}To test the scheduling itself, start the real runtime under Tokio's paused clock, where sleep advances virtual time instantly. This needs Tokio's test-util feature in [dev-dependencies] (tokio = { version = "1", features = ["test-util"] }):
#[tokio::test(start_paused = true)]
async fn the_first_run_happens_at_start() {
let store = SessionStore::default();
let (_services, cron) = ServiceBuilder::new(store.clone())
.cron_job::<ExpireSessions, _>()
.start();
// Half of the 60-second interval: only the first run has happened.
tokio::time::sleep(Duration::from_secs(30)).await;
cron.shutdown().await;
assert_eq!(store.expired.load(Ordering::Relaxed), 1);
}Most jobs only need the first kind of test: the schedule is wakuwaku's job and is tested there. See Testing for the general approach.
gRPC services
Generate tonic servers from a .proto file, build their implementations from the ServiceBuilder, and serve them behind shared middleware with graceful shutdown.
Events on Apache Iggy
How wakuwaku publishes and consumes typed events on Apache Iggy with per-key ordering, concurrent keys and algebraic batching.