Stacks Scheduler
The @stacksjs/scheduler package provides fluent, chainable task scheduling for Stacks applications. It wraps @stacksjs/cron for cron expression parsing and supports scheduling jobs, actions, and shell commands with timezone awareness, overlap prevention, and background execution.
Key Paths
- Scheduler package:
storage/framework/core/scheduler/src/ - Cron package:
storage/framework/core/cron/src/ - Application scheduler:
app/Scheduler.ts - CLI command:
storage/framework/core/buddy/src/commands/schedule.ts - Run action:
storage/framework/core/actions/src/schedule/run.ts - Queue-based scheduler:
storage/framework/core/queue/src/scheduler.ts - Job types /
Everyenum:storage/framework/core/types/src/cron-jobs.ts - Lock files:
storage/framework/locks/(created at runtime)
Source Files
scheduler/src/
├── index.ts # Re-exports everything from run, schedule, types
├── schedule.ts # Schedule class, Queue alias, sendAt(), timeout()
├── run.ts # runScheduler() — loads Jobs/*.ts + app/Scheduler.ts
└── types.ts # Timezone union, ScheduledJob, UntimedSchedule, TimedSchedule, BaseSchedule
cron/src/
├── index.ts # parse(), register(), remove() — delegates to Bun.cron or parseCron
├── parser.ts # parseCron() — native 5-field cron parser with POSIX OR logic
├── types.ts # CatchCallbackFn, ProtectCallbackFn, IntRange
└── bun-cron.d.ts # Bun.cron type declarations
queue/src/
└── scheduler.ts # startScheduler(), stopScheduler(), getSchedulerStatus() — queue-level scheduler
Schedule Class (schedule.ts)
The Schedule class is the core scheduling API. The lowercase schedule export is an alias for the class itself (used as a static-method namespace).
import { schedule } from '@stacksjs/scheduler'
// Static factory methods — each returns UntimedSchedule
schedule.job(name: JobName): UntimedSchedule // Runs a job by name via runJob()
schedule.action(name: string): UntimedSchedule // Runs an action by name via runAction()
schedule.command(cmd: string): UntimedSchedule // Runs a shell command via runCommand()
// Graceful shutdown — stops all tracked jobs
schedule.gracefulShutdown(): Promise<void>
Constructor
new Schedule(task: () => void)
The constructor accepts a task function and auto-starts the schedule via setTimeout(() => this.start(), 0) after all chained methods have been called in the current tick.
Timing Methods (returns TimedSchedule)
Each sets an internal cron pattern. Once a timing method is called, the schedule is "timed" and only configuration methods remain available.
| Method | Cron Pattern | Notes |
|---|---|---|
everySecond() |
@every_second |
Uses setInterval(1000), not cron |
everyMinute() |
* * * * * |
|
everyTwoMinutes() |
*/2 * * * * |
|
everyFiveMinutes() |
*/5 * * * * |
|
everyTenMinutes() |
*/10 * * * * |
|
everyThirtyMinutes() |
*/30 * * * * |
|
everyHour() / hourly() |
0 * * * * |
|
everyDay() / daily() |
0 0 * * * |
|
weekly() |
0 0 * * 0 |
Sunday at midnight |
monthly() |
0 0 1 * * |
1st of month at midnight |
yearly() / annually() |
0 0 1 1 * |
Jan 1 at midnight |
onDays(days: number[]) |
0 0 * * {days} |
e.g. onDays([1,3,5]) => 0 0 * * 1,3,5 |
at(time: string) |
{min} {hr} * * * |
e.g. at('14:30') => 30 14 * * * |
Configuration Methods (chainable, returns this)
setTimeZone(timezone: Timezone): this
withErrorHandler(handler: CatchCallbackFn): this
withMaxRuns(runs: number): this
withProtection(callback?: (job: ScheduledJob) => void): this
withName(name: string): this
withContext(context: any): this
withInterval(seconds: number): this
between(startAt: string | Date, stopAt: string | Date): this
withoutOverlapping(expiresAfterMinutes?: number): this
onOneServer(): this
runInBackground(): this
ScheduledJob Interface
interface ScheduledJob {
stop: () => void
nextRun: () => Date | null
}
Timezone Type
The Timezone type is a string union of ~70 IANA timezone identifiers (e.g., 'America/New_York', 'Europe/Berlin', 'Asia/Tokyo', 'UTC'). Default is 'America/Los_Angeles'.
Internal Scheduling Engine
- Sub-minute (
everySecond): UsessetIntervalwith the configuredintervalMs. - Minute+: Uses
parse()from@stacksjs/cronto compute the next run time, thensetTimeoutto fire at the right moment. When the delay exceeds2^31-1 ms(~24.8 days), it chains shorter timeouts. - Timezone-aware: Converts "now" to the configured timezone via
toLocaleString(), parses the cron pattern from that local time, then computes the real-world delay. - maxRuns: Tracks
runCountand callsstop()when the limit is reached. - Error handling: If
options.catchis set, errors are caught and passed to the handler instead of propagating.
Overlap Prevention and Locking
When withoutOverlapping() or onOneServer() is called:
- Lock files are created in
storage/framework/locks/{taskName}.lock - The lock directory is created automatically if missing
- Lock files use exclusive write (
flag: 'wx') for atomicity - Stale locks expire after
overlapExpiresAfterMinutes(default: 1440 = 24 hours) - Lock age is checked via file
mtime - Locks are released in a
finallyblock (or.finally()for async tasks)
Background Execution
When runInBackground() is called, the task is spawned as a detached child process via node:child_process.spawn. The child is unref()'d so it does not keep the parent alive.
Helper Functions
import { sendAt, timeout } from '@stacksjs/scheduler'
// Get the next Date a cron expression will fire
sendAt(cronExpression: string | Date): Date | null
// - String: delegates to parse() from @stacksjs/cron
// - Date: returns the date if it's in the future, null otherwise
// Get milliseconds until the next fire time
timeout(cronExpression: string | Date): number
// Returns -1 if no upcoming run
Cron Parser (@stacksjs/cron)
parse()
import { parse } from '@stacksjs/cron'
parse(expression: string, relativeDate?: Date | number): Date | null
- Uses
Bun.cron.parse()when available (native Bun cron support), otherwise falls back to the built-inparseCron()implementation. - Returns the next matching UTC
Date, ornullif no match within ~4 years. - Throws on invalid expressions (wrong field count, out-of-range values).
parseCron() (built-in parser)
Supports the standard 5-field format: minute hour dayOfMonth month dayOfWeek
Operators: * (all), , (list), - (range), / (step)
Named values: JAN-DEC, SUN-SAT (case-insensitive, full names also accepted)
Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
POSIX OR logic: When both dayOfMonth and dayOfWeek are specified (neither is *), the expression matches when either condition is true.
OS-Level Cron Registration
import { register, remove } from '@stacksjs/cron'
// Register a persistent OS-level cron job (requires Bun.cron native support)
await register(path: string, schedule: string, title: string)
// Remove a registered cron job by title
await remove(title: string)
These require Bun's native cron support (crontab on Linux, launchd on macOS, schtasks on Windows). The target script must export a scheduled(controller) handler.
Every Enum (cron-jobs.ts)
The Every enum maps human-readable intervals to cron expressions. Used in Job rate fields.
import { Every } from '@stacksjs/types'
Every.Second // '* * * * * *' (6-field, sub-minute)
Every.FiveSeconds // '*/5 * * * * *'
Every.TenSeconds // '*/10 * * * * *'
Every.ThirtySeconds // '*/30 * * * * *'
Every.Minute // '* * * * *'
Every.TwoMinutes // '*/2 * * * *'
Every.FiveMinutes // '*/5 * * * *'
Every.TenMinutes // '*/10 * * * *'
Every.FifteenMinutes // '*/15 * * * *'
Every.ThirtyMinutes // '*/30 * * * *'
Every.Hour // '0 * * * *'
Every.HalfHour // '0,30 * * * *'
Every.Day // '0 0 * * *'
Every.Week // '0 0 * * 0'
Every.Weekday // '0 0 * * 1-5'
Every.Weekend // '0 0 * * 0,6'
Every.Month // '0 0 1 * *'
Every.Year // '0 0 1 1 *'
runScheduler() (run.ts)
The entry point for starting the scheduler process:
- Globs
app/Jobs/*.tsfor job files - For each job with a
rateproperty, maps the rate to a schedule method viaexecuteJobRate()(switch onEvery.*values) - Job names are derived from
job.nameor the filename, thensnakeCase()'d - Imports and calls the default export from
app/Scheduler.ts - Returns
Ok<string>on success
import { runScheduler } from '@stacksjs/scheduler'
const result = await runScheduler()
Queue-Level Scheduler (queue/src/scheduler.ts)
A separate, queue-integrated scheduler that discovers jobs and dispatches them to the queue system:
import { startScheduler, stopScheduler, getSchedulerStatus, triggerJob } from '@stacksjs/queue'
await startScheduler(config?: Partial<SchedulerConfig>)
await stopScheduler()
getSchedulerStatus(): { isRunning, jobCount, jobs[] }
isSchedulerRunning(): boolean
getRegisteredJobs(): Map<string, ScheduledJobState>
await triggerJob(name: string) // Manually dispatch a scheduled job
SchedulerConfig
interface SchedulerConfig {
checkInterval: number // ms between checks (default: 60000)
timezone?: string
preventOverlapping: boolean // default: true
}
This scheduler polls on checkInterval, uses its own shouldRunNow() cron matcher (supports 5- and 6-field expressions), and dispatches jobs to the queue via storeJob() and emitQueueEvent().
app/Scheduler.ts
User-defined scheduled tasks live here. Must export a default function:
import { schedule } from '@stacksjs/scheduler'
export default function () {
schedule.job('Inspire').hourly().setTimeZone('America/Los_Angeles')
schedule.action('CleanupTempFiles').everyFiveMinutes()
schedule.command('echo "maintenance"').daily()
}
// Graceful shutdown on SIGINT
process.on('SIGINT', () => {
schedule.gracefulShutdown().then(() => process.exit(0))
})
CLI Commands
buddy schedule:run-- RunsAction.ScheduleRun, which callsrunScheduler()- Options:
-p, --project [project],--verbose
- Options:
Code Examples
Schedule a job with overlap prevention
schedule
.job('ProcessPayments')
.everyFiveMinutes()
.withoutOverlapping(30) // Lock expires after 30 minutes
.setTimeZone('America/New_York')
.withErrorHandler((err) => console.error('Payment processing failed:', err))
Schedule a command with max runs
schedule
.command('bun run cleanup')
.daily()
.withMaxRuns(7) // Stop after 7 executions
.withName('weekly-cleanup')
Schedule on specific days
schedule
.action('SendWeeklyReport')
.onDays([1, 3, 5]) // Mon, Wed, Fri at midnight
.setTimeZone('Europe/London')
Schedule at a specific time
schedule
.job('DailyDigest')
.at('09:00') // 9 AM daily
.setTimeZone('Asia/Tokyo')
Query next run time
import { sendAt, timeout } from '@stacksjs/scheduler'
const nextRun = sendAt('*/15 * * * *') // Next 15-min mark
const msUntil = timeout('0 0 * * *') // ms until next midnight
Job with rate-based scheduling
// app/Jobs/CleanupExpiredSessions.ts
import { Every } from '@stacksjs/types'
export default {
name: 'CleanupExpiredSessions',
rate: Every.Hour,
handle: async () => {
// cleanup logic
},
}
Gotchas
- The
Scheduleconstructor auto-starts viasetTimeout(0)-- all chained methods must be called synchronously in the same tick, or the schedule starts with incomplete configuration - Default timezone is
'America/Los_Angeles', not UTC everySecond()usessetInterval, not cron -- it setsintervalMs = 1000and bypasses the cron parser entirely- The
Queueclass inschedule.tsis just an empty subclass ofSchedule(export class Queue extends Schedule {}) -- it adds no functionality withoutOverlapping()uses file-based locks instorage/framework/locks/-- this only prevents overlap within a single machine, not across a clusteronOneServer()also uses file-based locks (same aswithoutOverlapping), so it does not actually coordinate across multiple serversrunInBackground()spawns a detached child process withspawn(process.execPath, ['-e', ...])-- the task function is.toString()'d and eval'd, so closures over external variables will not work- There are TWO scheduler systems:
@stacksjs/scheduler(fluent API inschedule.ts) and the queue-level scheduler in@stacksjs/queue(queue/src/scheduler.ts). The former runs tasks in-process; the latter dispatches to the queue sendAt()throws on invalid cron expressions (it delegates toparse()which throws)timeout()returns-1(not0orInfinity) when there is no upcoming run- The
Every.Second/FiveSeconds/TenSeconds/ThirtySecondsenum values use 6-field cron (with seconds), but the@stacksjs/cronparser only supports 5-field expressions -- the queue scheduler'sparseScheduleString()maps sub-minute intervals to'* * * * *'(every minute) runScheduler()silently catches and logs errors when individual job files fail to import -- a broken job file does not prevent other jobs from being scheduled- Named jobs via
withName()are tracked in a staticMap<string, ScheduledJob>on theScheduleclass --gracefulShutdown()iterates and stops all of them - The cron parser uses POSIX OR logic when both day-of-month and day-of-week are specified (neither
*) -- this means0 0 15 * FRImatches the 15th OR every Friday, not only Fridays that fall on the 15th - Lock files are written with
{ flag: 'wx' }for atomic creation, but this is not NFS-safe parse()returnsnullfor impossible patterns (e.g.,0 0 30 2 *-- Feb 30 never exists) rather than throwing