Skip to content

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.

Terminal window
cargo add nest-rs --features 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 (from the demo, 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 JobProducer, 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 (from the demo, abridged)
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.

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 (from the demo)
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 (from the demo)
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 (from the demo)
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.

Terminal window
$ 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=bd824ae7eb1f9e7a
DEBUG 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=052e261668035200

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.

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:

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. 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.

crates/features/src/posts/schedule/tasks.rs
#[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.

  • crates/features/src/audio/schedule/tasks.rs — the canonical multi-method exemplar.
  • crates/nest-rs-schedule/#[scheduled], ScheduleModule, Scheduler, CronExpression, Trigger.
  • 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).