CLI surface for the per-minute task scheduler. The three schedule:*
subcommands all delegate into your application binary's Application::run()
dispatch, so they see the same config, services, observers, and listeners
that a request handler does. The full scheduler model - Task trait, fluent
cron API, without_overlapping, run_in_background - lives in
Scheduling; this chapter is the operator reference for the
commands themselves.
How the commands run
suprnova schedule:run, suprnova schedule:work, and suprnova schedule:list
are thin shells that invoke cargo run -- schedule:<subcommand> against the
project in the current directory. The same subcommands are also reachable
directly on the application binary in production:
# In development (from the project root, source build):
# In production (binary on PATH):
The runtime drivers (Cache, Queue, RateLimit, Mail) and your
bootstrap_fn are booted before any task runs, so a scheduled task can
resolve services from the container exactly like a controller - see
Application Bootstrap.
You must wire the scheduler into the application builder for the subcommands to find any tasks:
// cmd/main.rs (backend starter) or src/main.rs (API starter)
new
.config
.bootstrap
.routes
.schedule // <-- the scheduler hook
.
.run
.await
suprnova make:task <Name> wires this automatically; if you build the
chain by hand, add the .schedule(...) call yourself.
schedule:run
Evaluate every registered task once and run the ones whose cron expression
matches the current minute. Designed to be invoked by system cron every
minute. Exits non-zero if any task failed; exits zero (with No tasks were due.) if nothing was due this minute.
Example output
Running due scheduled tasks...
✓ cleanup:logs
✓ send:reminders
When a task returns an error, its line is prefixed with ✗ and the error
message is appended:
Running due scheduled tasks...
✓ cleanup:logs
✗ backup:database: connection refused
When no task is due this minute:
Running due scheduled tasks...
No tasks were due.
Crontab entry
A single entry runs the scheduler every minute. The application binary evaluates all due tasks itself, so this is the only crontab line a production host needs:
* * * * * cd /path/to/your/project && /usr/local/bin/myapp schedule:run >> /var/log/myapp/schedule.log 2>&1
If you're running schedule:run from system cron on more than one host
(or alongside a schedule:work daemon), tasks marked
.without_overlapping() need a configured Cache backend
(CACHE_DRIVER=redis is the production-grade choice) to coordinate
across processes - see Preventing overlap
for the lock semantics.
schedule:work
Run the scheduler as a long-lived daemon. The first tick is aligned to the
next minute boundary, then the loop evaluates due tasks once per minute
until it receives SIGINT (Ctrl-C) or SIGTERM. On shutdown, any
run_in_background tasks still in flight are awaited before exit so they
don't get torn down mid-write.
Example output
Starting scheduler daemon...
Press Ctrl+C to stop
==============================================
suprnova Scheduler Daemon
==============================================
3 task(s) registered. Press Ctrl+C to stop.
==============================================
Each tick is quiet - only failures are logged. On shutdown:
suprnova: scheduler shutting down.
suprnova: waiting for 1 background task(s) to finish…
Scheduler daemon stopped.
Use cases
- Development. No crontab required - start the daemon in a terminal and watch it tick.
- Docker. Use as the container's main process when you want one image to play the scheduler role.
- Systemd. Manage it as a long-running unit (see systemd unit below).
systemd unit
# /etc/systemd/system/myapp-scheduler.service
[Unit]
Description=MyApp Scheduler
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/your/project
ExecStart=/usr/local/bin/myapp schedule:work
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Restart=always brings the daemon back up if it crashes; RestartSec=5
debounces a crash loop. Because the framework's panic boundary catches
panicking tasks and converts them to FrameworkError, a single bad task
should not crash the daemon - Restart=always is for the rare process-wide
failure (OOM, parent kill).
schedule:list
Print every registered task with its cron expression, next run time, and description.
Example output
Registered scheduled tasks:
cleanup:logs [0 3 * * *] next: 2026-05-29 03:00 UTC
send:reminders [0 9 * * *] next: 2026-05-28 09:00 UTC
heartbeat [* * * * *] next: 2026-05-28 12:01 UTC
report:generate [0 6 * * *] (UTC) next: 2026-05-29 06:00 UTC
Tasks with a .description(...) chained on the builder include the
description after the next run time; tasks without a description show only
the cron and the next run.
next: is the first minute after now at which the expression matches; an
expression that can never match prints next: never. Times are shown in
UTC unless --timezone names another IANA zone. The CLI hands the flag to
your application without checking it, and the application exits with an
error before it prints anything when the zone name is unknown.
A task that pinned its own zone with .timezone(...) has its expression
rewritten into the listing's zone and labelled with it - report:generate
above asked for 02:00 America/New_York. Tasks without a pinned zone are
printed as written and carry no label. See
Scheduling for the timezone rules in full, including when
a rewrite is refused and one task can occupy several lines.
When nothing is registered (the .schedule(...) builder call is missing,
or schedule::register is a no-op):
No scheduled tasks registered.
Define tasks in src/schedule.rs and wire it with `Application::schedule(schedule::register)`.
Generating a task
The framework ships a generator that creates the task, wires it into the
project, and adds the scheduler call to your main.rs:
This:
- Creates
src/tasks/cleanup_logs_task.rs(a workingTaskstub that logs its own duration) - Creates
src/tasks/mod.rs(re-exportingCleanupLogsTask) if it doesn't already exist - Creates
src/schedule.rs(with aregister(&mut Schedule)function) if it doesn't already exist - Declares
pub mod schedule;andpub mod tasks;insrc/lib.rs - Adds
.schedule(<crate>::schedule::register)to theApplicationchain incmd/main.rs(orsrc/main.rsfor the API starter)
Steps 2-5 are idempotent, so re-running make:task repairs wiring that
was removed by hand. See Generators for the broader
make:* family.
After generating, register the task in src/schedule.rs:
use Schedule;
use crateCleanupLogsTask;
The fluent builder API (.daily(), .cron(...), .without_overlapping(),
.run_in_background(), day-specific modifiers) is fully covered in
Scheduling.
Exit codes
| Command | Exit zero | Exit non-zero |
|---|---|---|
schedule:run |
every due task returned Ok(()), or no tasks were due |
at least one task returned Err(_) or panicked |
schedule:work |
clean shutdown via SIGINT / SIGTERM (the wrapper treats exit code 130 as clean Ctrl-C) |
bootstrap failure, or the daemon process aborted |
schedule:list |
listing succeeded (including the "no tasks registered" message) | application failed to boot |
Background-task failures inside schedule:work are logged to stderr but
do not exit the daemon - the JoinSet's catch_unwind boundary surfaces
them as FrameworkError and the tick loop continues.
Why Suprnova diverges
Laravel's schedule:run is the only first-class entry point; the daemon
form (schedule:work) is a backport for hosts without crontab. PHP has
no long-lived process, so each minute is a fresh runtime that has to
re-boot the framework, the container, and every service binding.
In Suprnova the daemon is first-class. schedule:work runs inside the
same Tokio runtime that serves HTTP, so:
- Background tasks compose with the tick loop. A
.run_in_background()task is spawned into aJoinSet; the loop polls completed ones before the next tick and drains the rest on shutdown. Laravel spawns a child process per background task. - Graceful shutdown drains in-flight work. Ctrl-C / SIGTERM lets inline tasks finish their current call and awaits every background spawn before exit. Laravel relies on the OS to kill the cron child.
- Boot cost is paid once. The container, drivers, and your
bootstrap_fnboot at daemon start, not at every tick.schedule:runstill pays the boot cost per invocation (it's a single-shot subcommand), but the daemon path is where the runtime model pays off.
schedule:run still works (and is the right choice when system cron is
already the operator's source of truth). Pick whichever fits your
deployment shape - both share the same task definitions.
Next
- Scheduling - the
Tasktrait, fluent cron API,without_overlapping,run_in_background, and same-minute dedup - Generators - the full
make:*family, includingmake:task - Console -
#[command]-annotated one-shot operator tasks (not on a schedule) - Queues - for work that should be picked up by a worker rather than tick on a clock
- Application Bootstrap - how
.schedule(...)plugs into the builder, and what tasks can resolve from the container
