Manual contentsCLI ReferenceBrowse 113 chapters
Manual 7 min read

Scheduling Commands

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):
suprnova schedule:run

# In production (binary on PATH):
/usr/local/bin/myapp schedule:run

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)
Application::new()
    .config(my_app::config::register)
    .bootstrap(my_app::bootstrap::bootstrap)
    .routes(my_app::routes::register)
    .schedule(my_app::schedule::register)   // <-- the scheduler hook
    .migrations::<my_app::migrations::Migrator>()
    .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.

suprnova schedule:run

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.

suprnova schedule:work

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
sudo systemctl daemon-reload
sudo systemctl enable myapp-scheduler
sudo systemctl start myapp-scheduler

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.

suprnova schedule:list
suprnova schedule:list --timezone=Asia/Tokyo

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:

suprnova make:task CleanupLogs

This:

  1. Creates src/tasks/cleanup_logs_task.rs (a working Task stub that logs its own duration)
  2. Creates src/tasks/mod.rs (re-exporting CleanupLogsTask) if it doesn't already exist
  3. Creates src/schedule.rs (with a register(&mut Schedule) function) if it doesn't already exist
  4. Declares pub mod schedule; and pub mod tasks; in src/lib.rs
  5. Adds .schedule(<crate>::schedule::register) to the Application chain in cmd/main.rs (or src/main.rs for 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 suprnova::Schedule;
use crate::tasks::CleanupLogsTask;

pub fn register(schedule: &mut Schedule) {
    schedule.add(
        schedule.task(CleanupLogsTask::new())
            .daily()
            .at("03:00")
            .name("cleanup:logs")
            .description("Removes logs older than 30 days")
    );
}

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 a JoinSet; 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_fn boot at daemon start, not at every tick. schedule:run still 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 Task trait, fluent cron API, without_overlapping, run_in_background, and same-minute dedup
  • Generators - the full make:* family, including make: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