Skip to content

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.

Terminal window
cargo add nest-rs-schedule

A regular #[injectable] service with one decorated method. The #[scheduled] attribute marks the impl block; #[every("5s")] marks the method as a recurring job.

crates/features/src/audio/schedule/tasks.rs (abridged)
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] makes AudioTasks a 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 returns Err; 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, the TranscodeDto shape — lives inside AudioService::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.

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:

crates/features/src/audio/schedule/tasks.rs (abridged)
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.

FormMeaning
#[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.

The provider goes in its feature module like any other #[injectable]:

crates/features/src/audio/schedule/module.rs
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 ScheduleModuleScheduleModule contributes the scheduler to App::run itself:

apps/api/src/module.rs
use nest_rs_core::module;
use nest_rs_schedule::ScheduleModule;
use features::audio::{AudioHttpModule, AudioScheduleModule};
#[module(imports = [
AudioHttpModule,
AudioScheduleModule,
ScheduleModule,
])]
pub struct ApiModule;
apps/api/src/main.rs
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.

Terminal window
$ 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=audio

The 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:

Terminal window
$ nestrs run dev api
error: 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")]
| ^^^^^^^^^^^^^^^

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.

  • Queue — pair a scheduled producer with a durable consumer for distributed work.
  • OpenTelemetry — every schedule event is targeted on nest_rs::schedule, carrying provider and method fields (plus interval_ms / delay_ms / timezone on the boot lines).
  • crates/features/src/audio/schedule/tasks.rs — the canonical multi-method exemplar.
  • crates/nest-rs-schedule/#[scheduled], ScheduleModule, Scheduler, CronExpression, Trigger.