Skip to content

Task Scheduling

Oxide’s scheduler turns a single cron invocation (oxide schedule:run, called every minute) into a registry of timed tasks declared in Rust. Any console command you register can be scheduled — built-ins like auth:prune-tokens, or custom Command impls you wrote.

Declare the schedule

Implement the schedule hook on any ServiceProvider. The idiomatic place is ConsoleServiceProvider:

src/providers/console_service_provider.rs
use oxide::http::{Application, ServiceProvider};
use oxide::Schedule;
pub struct ConsoleServiceProvider;
impl ServiceProvider for ConsoleServiceProvider {
fn register(&self, app: &Application) {
app.command(SyncProductCommand);
}
fn schedule(&self, sched: &mut Schedule) {
sched.command("auth:prune-tokens").daily();
sched.command("sync:product").hourly();
sched.command("report:weekly").cron("0 9 * * 1");
sched.command("sync:product").with_args(["--force"]).every_fifteen_minutes();
}
}

Wire the cron entry

Add one line to your crontab (crontab -e):

* * * * * cd /path/to/your/app && oxide schedule:run >> storage/logs/schedule.log 2>&1

Cron invokes oxide schedule:run every minute. Inside the binary, Application::schedules() collects every provider’s schedule, tests each entry’s cron expression against the current minute, and dispatches matches via Application::run_command.

Timing helpers

Any ScheduleEntry supports a Laravel-compatible fluent API. Every method returns &mut Self so they chain:

MethodCron equivalent
.every_minute()* * * * *
.every_five_minutes()*/5 * * * *
.every_fifteen_minutes()*/15 * * * *
.every_thirty_minutes()0,30 * * * *
.hourly()0 * * * *
.daily()0 0 * * *
.daily_at("02:30")30 2 * * *
.weekly()0 0 * * 0
.monthly()0 0 1 * *
.cron("min hour dom month dow")raw Laravel-style 5-field

All times are interpreted in UTC. Convert to a specific zone inside the command if needed.

Passing arguments

with_args takes anything that iterates into String:

sched.command("sync:product")
.with_args(["--force", "--limit=500"])
.every_fifteen_minutes();

The args are passed through to Command::handle(&self, args: Vec<String>) exactly as if you’d typed oxide sync:product --force --limit=500.

Inspect the registered schedule

Call application.schedules() anywhere you have an &Application, for example inside a diagnostic command:

for entry in app.schedules().entries() {
println!("{} {:<30} args={:?}", entry.cron, entry.signature, entry.args);
}

What schedule:run does

Every invocation:

  1. Collects schedules from every registered provider’s schedule() hook.
  2. Truncates the current UTC time to the minute.
  3. For each entry, parses the cron expression and checks if the next firing after (now - 1s) equals the current minute.
  4. Matches run via Application::run_command. Results are logged at info (success) or warn/error (failure) via tracing.
  5. Exit code: 0 if every dispatched task returned 0, otherwise 1.

Operational tips

  • Don’t let schedule:run overrun. Cron invokes it every minute; if your tasks take longer than a minute, they can pile up. Move long-running work into a background queue and have the scheduled task only enqueue.
  • Logging. Redirect both stdout and stderr from cron:
    >> storage/logs/schedule.log 2>&1
    otherwise cron emails any stderr output to the system mail spool.
  • Monitoring. Point a watchdog (Dead Man’s Snitch, Uptime Kuma) at a healthcheck ping emitted from inside schedule:run so you notice when cron itself dies.

What’s not here yet

  • Overlap locks. .without_overlapping() is on the roadmap and will use the forthcoming cache layer for atomic lock acquisition. For now, design tasks to be idempotent.
  • Conditional execution. .when(|| bool) / .skip(|| bool) — coming alongside the cache layer.
  • Sub-minute granularity. Cron itself is minute-granularity; we don’t yet loop internally inside schedule:run to fire multiple times per minute. Open an issue if you need it.