Schedule
A scheduled job is a method on any #[injectable] provider, tagged with
#[every], #[cron], or #[after] inside a #[scheduled] impl block.
Importing ScheduleModule in your AppModule attaches the scheduler at
boot.
Triggers are validated at compile time (string literals) or at boot
(CronExpression presets, IANA timezones); a bad value fails the boot
naming the offending job.
nest-rs-schedule builds on croner — the framework
wraps it for cron expression parsing (5/6/7-field), with chrono /
chrono-tz for the optional IANA timezone.
Install
Section titled “Install”cargo add nest-rs-scheduleA first scheduled method
Section titled “A first scheduled method”A regular #[injectable] service with one decorated method. The #[scheduled]
attribute marks the impl block; #[every("5s")] marks the method as a
recurring job.
use std::sync::Arc;use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;use nest_rs_core::injectable;use nest_rs_schedule::scheduled;
use crate::audio::AudioService;
#[injectable]pub struct AudioTasks { #[inject] svc: Arc<AudioService>,}
#[scheduled]impl AudioTasks { #[every("5s")] async fn enqueue_transcode(&self) -> Result<()> { let id = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); self.svc.enqueue_transcode(format!("track-{id}.mp3")).await }}#[injectable]makesAudioTasksa regular DI provider — same#[inject]shape as any other service.#[scheduled]on the impl block orchestrates the per-method trigger attributes and submits one cron entry per decorated method.#[every("5s")]runs the method on a fixed interval. A failure returnsErr; it is logged and the schedule continues — one failed run never stops the job.- The cron method is thin. Its only job is to decide when and
what payload (here a synthetic file id). The enqueue plumbing —
the
QueueConnection, the queue name, theTranscodeDtoshape — lives insideAudioService::enqueue_transcode. The same service call is used by the HTTP producer and by this scheduler, so there is exactly one place where the “enqueue an audio job” decision lives.
Several methods on the same provider
Section titled “Several methods on the same provider”This is the pattern the framework is built for. A single #[injectable]
owns the deps once, and as many decorated methods as you need share them.
Mix triggers freely:
use nest_rs_schedule::{scheduled, CronExpression};
use crate::audio::AUDIO_QUEUE;
#[injectable]pub struct AudioTasks { #[inject] svc: Arc<AudioService>,}
#[scheduled]impl AudioTasks { #[every("5s")] async fn enqueue_transcode(&self) -> Result<()> { let id = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); self.svc.enqueue_transcode(format!("track-{id}.mp3")).await }
#[after("3s")] async fn warmup_on_boot(&self) -> Result<()> { tracing::info!( target: "features::audio", phase = "warmup", "audio pipeline ready to enqueue", ); Ok(()) }
#[cron(CronExpression::EVERY_MINUTE)] async fn heartbeat(&self) -> Result<()> { tracing::info!( target: "features::audio", queue = AUDIO_QUEUE, "audio producer heartbeat", ); Ok(()) }}Three cron entries land in the scheduler — AudioTasks::enqueue_transcode,
AudioTasks::warmup_on_boot, AudioTasks::heartbeat — all pointing at the
same AudioTasks instance. Same Arc<AudioService>, three triggers, no
ceremony. Each method calls into the service or logs; none of them
build a queue payload or reach for a connection.
Triggers
Section titled “Triggers”| Form | Meaning |
|---|---|
#[every("30s")] | Fixed interval (ms, s, m, h) |
#[after("10s")] | One-shot — runs N after boot, then never |
#[cron("0 */5 * * * *")] | 6-field cron expression |
#[cron(CronExpression::EVERY_5_SECONDS)] | Named preset (compile-time validated) |
#[cron("0 9 * * MON", tz = "Europe/Paris")] | Cron + named timezone (default UTC) |
A literal cron expression is validated at compile time; presets and timezones at boot. Either way, an invalid trigger fails the boot naming the job and the offending value — never silently skipped.
Wire it in
Section titled “Wire it in”The provider goes in its feature module like any other #[injectable]:
use nest_rs_core::module;
use super::tasks::AudioTasks;use crate::audio::AudioModule;
#[module(imports = [AudioModule], providers = [AudioTasks])]pub struct AudioScheduleModule;The module imports AudioModule because AudioTasks injects
Arc<AudioService>. Forget the import and the access graph fails the
boot with a clear message naming AudioService as the missing
dependency — the same Rust-visibility rule every other adapter follows.
The app activates the scheduler by importing ScheduleModule —
ScheduleModule contributes the scheduler to App::run itself:
use nest_rs_core::module;use nest_rs_schedule::ScheduleModule;use features::audio::{AudioHttpModule, AudioScheduleModule};
#[module(imports = [ AudioHttpModule, AudioScheduleModule, ScheduleModule,])]pub struct ApiModule;use nest_rs_core::App;use api::ApiModule;
#[tokio::main]async fn main() -> anyhow::Result<()> { App::builder() .module::<ApiModule>() .build() .await? .run() .await}main carries no transport at all — every transport (HttpModule,
ScheduleModule, QueueWorkerModule) is imported in ApiModule and
attaches itself at boot.
Without the ScheduleModule import, every #[scheduled] method
compiles in but never ticks — a deliberate choice: an HTTP-only app
that links the features crate does not accidentally run a worker’s
schedule. The framework only ticks methods whose provider is reachable
from the app’s module tree.
Run it
Section titled “Run it”$ nestrs run dev api… INFO nest_rs::transport: attached module-contributed transport transport=Scheduler INFO nest_rs::schedule: scheduled job (interval) provider=AudioTasks method=enqueue_transcode interval_ms=5000 INFO nest_rs::schedule: scheduled job (one-shot) provider=AudioTasks method=warmup_on_boot delay_ms=3000 INFO nest_rs::schedule: scheduled job (cron) provider=AudioTasks method=heartbeat timezone=UTC INFO features::audio: audio pipeline ready to enqueue phase=warmup INFO features::audio: enqueued transcode job file=track-1717405126521.mp3 INFO features::audio: audio producer heartbeat queue=audioThe scheduler logs one line per job at boot, then stays quiet on a
successful tick — the service logs are what you see running. A failed run
adds one scheduled job failed line at error; the schedule keeps
ticking.
A bad cron literal never reaches boot — it fails cargo build:
$ nestrs run dev apierror: invalid cron expression: Invalid pattern: Pattern must have between 5 and 7 fields. --> crates/features/src/audio/schedule/tasks.rs:35:12 |35 | #[cron("every monday")] | ^^^^^^^^^^^^^^^The ambient data context
Section titled “The ambient data context”A scheduled method has no caller — system work, intentionally. The
framework installs a pool executor in the ambient data context (Repo
reads go through the pool) and no ability is installed (Repo reads
are unscoped, correct for system work).
#[injectable]pub struct CleanupTasks { #[inject] svc: Arc<PostsService>,}
#[scheduled]impl CleanupTasks { #[every("1h")] async fn delete_expired(&self) -> Result<()> { self.svc.delete_expired().await }}Just importing DatabaseModule is enough — no manual connection plumbing.
Going further
Section titled “Going further”- Queue — pair a scheduled producer with a durable consumer for distributed work.
- OpenTelemetry — every schedule event is targeted on
nest_rs::schedule, carryingproviderandmethodfields (plusinterval_ms/delay_ms/timezoneon the boot lines).
Reference
Section titled “Reference”crates/features/src/audio/schedule/tasks.rs— the canonical multi-method exemplar.crates/nest-rs-schedule/—#[scheduled],ScheduleModule,Scheduler,CronExpression,Trigger.