Schedule
In-process cron and interval jobs — declared as methods on any provider, activated by importing one module.
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 --features 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
JobProducer, 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::core::injectable;use nest_rs::schedule::{scheduled, CronExpression};
use nest_rs::queue::QueueName;
use crate::audio::AudioQueue;
#[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 = AudioQueue::NAME, "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, RedisWorkerModule) 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::app: 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" trace_id=01a015a91d527cb1b9d15a8ba7fe8846 span_id=bd824ae7eb1f9e7a INFO nest_rs::operation: schedule.tick provider="AudioTasks" method="warmup_on_boot" outcome="ok" duration_ms=0.302 trace_id=01a015a91d527cb1b9d15a8ba7fe8846 span_id=bd824ae7eb1f9e7aDEBUG features::audio: enqueued transcode job file="track-1787069801760.mp3" trace_id=01a015a9252076e399f736a97ae90784 span_id=052e261668035200 INFO nest_rs::operation: schedule.tick provider="AudioTasks" method="enqueue_transcode" outcome="ok" duration_ms=101.901 trace_id=01a015a9252076e399f736a97ae90784 span_id=052e261668035200The 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.
Every tick opens its own scheduled job span, so the lines your task
writes are already correlated: a fresh trace_id each time (a clock
has nothing upstream to continue) and no actor_id (nobody is being
served). See Correlation.
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. So no
ability is installed and Repo reads are unscoped, which is correct for
work with no principal to scope to.
The executor is a transaction per attempt: it opens on the job’s first
Repo call, commits when the method returns Ok and rolls back otherwise,
so a tick that failed halfway leaves nothing behind for the next one. Add
transactional = false to the trigger — #[every("30s", transactional = false)], and the same word on #[cron], #[after] and #[process] — to
run on the pool instead, which is right for a tick that brackets long work
that is not the database’s. See
Repo and the executor.
#[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 SeaOrmDatabaseModule is enough — no manual connection plumbing.
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.
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).