Digging Deeper
Task Scheduling
Introduction
JCC Express MVC ships with a Laravel-inspired task scheduler. Define recurring tasks — callbacks, Artisan commands, or raw shell commands — using a fluent builder, then drive them with a single long-running process.
Tasks are defined in app/Console/Schedule.ts and started via the Artisan CLI. The scheduler checks for due tasks every 60 seconds. When at least one sub-minute task is registered, the tick automatically drops to every 1 second.
Defining tasks
Open app/Console/Schedule.ts and register tasks inside the exported schedule function:
The file is loaded automatically by bun artisanNode schedule:run.
Starting the scheduler
For production you can also drive it from the system crontab:
* * * * * cd /path/to/app && bun artisanNode schedule:run:once
Registering tasks
Four methods are available on the Schedule instance:
Frequency options
All methods are chainable on the builder returned by call(), command(), artisan(), or exec().
Sub-minute
Sub-minute tasks switch the scheduler tick to every 1 second automatically.
Minutes
Hours
Days
Day of month
Weeks
Months
Quarters and years
Custom cron expression
Day constraints
Named day shortcuts
Day groups
Time of day — at()
Override just the hour and minute on the current expression:
Time window constraints
between(startHour, endHour)
Only run while the current hour is inside the window:
unlessBetween(startHour, endHour)
Skip the task while the current hour is inside the window. Supports windows that wrap midnight:
Conditional execution
when(condition)
Only run when the condition returns true. Accepts both sync and async functions:
If the condition returns false the task is skipped entirely — no locks are acquired.
Environment constraints
environments(...envs)
Restrict a task to specific environments. Reads APP_ENV, falling back to NODE_ENV, case-insensitive:
Timezones
Evaluate the task's schedule in a specific timezone:
Preventing overlaps
By default a task can start again even if the previous run is still in progress. withoutOverlapping() skips the new run instead:
A lock file at storage/framework/schedule/overlap-<hash>.lock holds the running PID. If the process died without releasing the lock, the scheduler detects the stale PID and takes over automatically.
Background execution
Run a task without blocking the scheduler loop:
Combine with withoutOverlapping() — the overlap lock is still acquired and released correctly:
Single-server tasks
In a multi-server setup, ensure only one server runs the task:
Uses storage/framework/schedule/server-<hash>.lock. Other servers see the file and skip the task as long as the owning PID is alive.
Combine with withoutOverlapping() — both locks must be acquired:
Task descriptions
Add a label used in logs and schedule:list:
How locks work
| Lock type | File | Released when |
|---|---|---|
overlap | storage/framework/schedule/overlap-<hash>.lock | Task finishes (success or error) |
server | storage/framework/schedule/server-<hash>.lock | Task finishes (success or error) |
Acquisition steps:
- Create the file atomically (
O_EXCL). - If the file already exists, read its PID and check if that process is alive.
- If the process is dead, overwrite the stale lock and proceed.
Locks are also released on graceful shutdown (SIGINT / SIGTERM).