Scheduler Package
On this page 43
A powerful task scheduling system for running recurring jobs, commands, and actions with cron-like syntax, timezone support, and overlapping prevention.
Installation
bun add @stacksjs/scheduler
Basic Usage
import { schedule } from '@stacksjs/scheduler'
// Schedule a job to run every minute
schedule.job('ProcessNewsletter').everyMinute()
// Schedule an action
schedule.action('CleanupTempFiles').daily()
// Schedule a command
schedule.command('db:backup').dailyAt('02:00')
Scheduling Methods
Job Scheduling
Schedule jobs defined in app/Jobs/:
import { schedule } from '@stacksjs/scheduler'
// Basic job scheduling
schedule.job('SendDailyReport').daily()
// Job with custom timing
schedule.job('ProcessPayments')
.everyFiveMinutes()
.setTimeZone('America/New_York')
// Job with error handling
schedule.job('SyncInventory')
.hourly()
.withErrorHandler((error) => {
console.error('Sync failed:', error)
// Send notification
})
Action Scheduling
Schedule actions defined in app/Actions/:
// Schedule an action
schedule.action('CleanupExpiredTokens').daily()
// Action with specific time
schedule.action('GenerateSitemap').dailyAt('04:00')
// Action with timezone
schedule.action('SendMarketingEmails')
.weekdays()
.at('09:00')
.setTimeZone('America/Los_Angeles')
Command Scheduling
Schedule shell commands:
// Run a shell command
schedule.command('npm run build').daily()
// Run database backup
schedule.command('pg_dump mydb > backup.sql').dailyAt('03:00')
// Run with specific working directory
schedule.command('bun run seed').weekly()
Frequency Options
Every X Minutes/Hours
// Every second
schedule.job('PingHealth').everySecond()
// Every minute
schedule.job('CheckQueue').everyMinute()
// Every two minutes
schedule.job('ProcessWebhooks').everyTwoMinutes()
// Every five minutes
schedule.job('SyncCache').everyFiveMinutes()
// Every ten minutes
schedule.job('UpdateStats').everyTenMinutes()
// Every thirty minutes
schedule.job('RefreshTokens').everyThirtyMinutes()
// Every hour
schedule.job('CleanupLogs').everyHour()
// Or use alias
schedule.job('CleanupLogs').hourly()
// Every day
schedule.job('DailyReport').everyDay()
// Or use alias
schedule.job('DailyReport').daily()
Specific Times
// Daily at specific time
schedule.job('Backup').at('02:30') // 2:30 AM daily
// Weekly
schedule.job('WeeklyReport').weekly() // Sunday at midnight
// Monthly
schedule.job('MonthlyInvoices').monthly() // 1st of month at midnight
// Yearly
schedule.job('AnnualCleanup').yearly()
// Or use alias
schedule.job('AnnualCleanup').annually()
Day-Based Scheduling
// Specific days of week (0 = Sunday, 6 = Saturday)
schedule.job('WeekdayTask').onDays([1, 2, 3, 4, 5]) // Monday to Friday
// A day method keeps a time already set, and a time method keeps the days,
// so these two agree:
schedule.job('Digest').onDays([2]).at('09:00') // 0 9 * * 2
schedule.job('Digest').at('09:00').onDays([2]) // 0 9 * * 2
Any Cron Expression
// Five fields
schedule.job('QuarterHourly').cron('*/15 * * * *')
// Or a job's rate, which is what a `rate` on a job file runs through
schedule.job('WeekdayDigest').cron(Every.Weekday)
// Six fields, with leading seconds: every N seconds
schedule.job('Heartbeat').cron('*/10 * * * * *')
The expression is checked when it is set. One that does not parse, never
matches (0 0 31 2 *), or names particular seconds (15 * * * * *, which the
scheduler cannot honour) throws instead of never running.
Scheduling Options
Timezone
// Set timezone for schedule
schedule.job('EmailDigest')
.daily()
.at('09:00')
.setTimeZone('Europe/London')
// Common timezones:
// 'America/New_York', 'America/Los_Angeles', 'America/Chicago'
// 'Europe/London', 'Europe/Paris', 'Europe/Berlin'
// 'Asia/Tokyo', 'Asia/Singapore', 'Australia/Sydney'
Error Handling
schedule.job('CriticalTask')
.hourly()
.withErrorHandler((error) => {
// Log error
console.error('Task failed:', error)
// Send notification
notify.slack(`Critical task failed: ${error.message}`)
// You can rethrow to mark as failed
throw error
})
Max Runs
// Limit number of executions
schedule.job('OneTimeSetup')
.everyMinute()
.withMaxRuns(1) // Only run once
// Run 10 times then stop
schedule.job('LimitedTask')
.hourly()
.withMaxRuns(10)
Protection (Overlapping Prevention)
withProtection() skips a tick while the previous run is still going in the same
process. Use withoutOverlapping() when several processes or servers run the
scheduler, since it takes a lock.
// Prevent overlapping runs
schedule.job('LongRunningTask')
.everyMinute()
.withProtection()
// With callback when protected
schedule.job('LongRunningTask')
.everyMinute()
.withProtection((job) => {
console.log('Job still running, skipping this run')
})
Naming
// Give schedule a name for identification
schedule.job('ProcessEmails')
.everyFiveMinutes()
.withName('email-processor')
Context
The context is handed to the task on every run: as the argument of a
new Schedule(fn) callback, and as the context of a scheduled job.
// Pass context to scheduled task
schedule.job('ProcessBatch')
.hourly()
.withContext({
batchSize: 100,
retryCount: 3
})
Interval
withInterval(seconds) sets the shortest gap between two runs. A tick that
comes sooner after the last run started is skipped, so it thins out a schedule
rather than adding runs to it.
// Every five minutes, but never twice within ten
schedule.job('CustomInterval')
.everyFiveMinutes()
.withInterval(600)
Date Range
Ticks before startAt are skipped, and the task stops for good once stopAt
has passed. An invalid date, or a stopAt that is not after startAt, throws.
// Run only between specific dates
schedule.job('CampaignTask')
.daily()
.between('2024-01-01', '2024-12-31')
// Run starting from a date
schedule.job('NewFeature')
.hourly()
.between(new Date(), new Date('2025-01-01'))
Schedule Helpers
Get Next Run Time
import { sendAt } from '@stacksjs/scheduler'
// Get next run time for a cron pattern
const nextRun = sendAt('0 9 _ _ _') // Next 9 AM
console.log(nextRun) // Date object
Get Timeout Until Next Run
import { timeout } from '@stacksjs/scheduler'
// Get milliseconds until next run
const ms = timeout('0 9 _ _ _')
console.log(`Next run in ${ms}ms`)
Graceful Shutdown
import { schedule, Schedule } from '@stacksjs/scheduler'
// Set up signal handlers
process.on('SIGINT', async () => {
await Schedule.gracefulShutdown()
process.exit(0)
})
process.on('SIGTERM', async () => {
await Schedule.gracefulShutdown()
process.exit(0)
})
Running the Scheduler
CLI
# Start the scheduler
buddy schedule:run
# Start with verbose output
buddy schedule:run --verbose
# List all scheduled jobs
buddy schedule:list
Programmatic
import { runScheduler } from '@stacksjs/scheduler'
// Start the scheduler
await runScheduler()
Cron Expression Reference
The scheduler uses standard cron expressions:
┌───────────── second (0 - 59) (optional)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday = 0)
│ │ │ │ │ │
_ _ _ _ _ _
Common patterns:
_ _ _ _ _ _ - Every minute
_ _/5 _ _ _ _ - Every 5 minutes
_ 0 _ _ _ _ - Every hour
_ 0 0 _ _ _ - Daily at midnight
_ 0 0 _ _ 0 - Weekly on Sunday
_ 0 0 1 _ _ - Monthly on 1st
_ 0 9 _ _ 1-5 - Weekdays at 9 AM
Job Definition Example
Define jobs in app/Jobs/:
// app/Jobs/SendDailyReport.ts
export default {
// Job name
name: 'SendDailyReport',
// Description
description: 'Send daily analytics report',
// Handle method
async handle(payload?: any) {
const report = await generateReport()
await sendEmail({
to: 'team@example.com',
subject: 'Daily Report',
body: report
})
},
// Retry configuration
tries: 3,
backoff: [60, 300, 600], // Retry delays in seconds
// Timeout (seconds)
timeout: 120,
// Queue name (if using queue driver)
queue: 'reports'
}
Integration with Queue
Scheduled jobs can be dispatched to the queue:
// Schedule job to be queued
schedule.job('ProcessLargeDataset')
.daily()
.onQueue('heavy') // Dispatch to 'heavy' queue
// The job will be added to the queue at scheduled time
// and processed by queue workers
Without onQueue(), a scheduled job runs inside the scheduler process. Only
schedule.job(...) takes it: an action, command or callback has no job to
dispatch, and calling it on one throws.
Edge Cases
Handling Missed Runs
A scheduler that was down misses the slots that fell in the gap. runMissed()
replays them: it fires the task once for each slot since since, keeping the
most recent max (default 100) when there were more, in the task's timezone.
Storing when the task last ran is up to you.
await schedule.job('ImportantTask')
.hourly()
.runMissed({ since: lastRunAt, max: 24 })
Long-Running Tasks
// For tasks that may exceed schedule interval
schedule.job('SlowTask')
.everyMinute()
.withProtection() // Skips a tick while the last run is still going
Time Zone Edge Cases
// Handle daylight saving time
schedule.job('TimeSensitive')
.dailyAt('02:30')
.setTimeZone('America/New_York')
// Note: 2:30 AM may not exist or happen twice during DST transitions
Error Recovery
schedule.job('FailProne')
.hourly()
.withErrorHandler(async (error) => {
// Log to external service
await errorTracker.capture(error)
// Don't rethrow - job is marked as complete
// Rethrow to trigger retry logic
})
Configuration
Environment Variables
# Scheduler settings
SCHEDULE_TIMEZONE=America/New_York
SCHEDULE_LOG_LEVEL=info
# Queue integration
QUEUE_DRIVER=database
Scheduler Configuration
// config/scheduler.ts
export default {
// Default timezone
timezone: 'UTC',
// Log scheduled runs
logging: true,
// Catch up missed runs
catchUp: false,
// Default error handler
onError: (error, job) => {
console.error(`Job ${job} failed:`, error)
}
}
API Reference
Schedule Methods
| Method | Description |
|---|---|
schedule.job(name) | Schedule a job |
schedule.action(name) | Schedule an action |
schedule.command(cmd) | Schedule a command |
Frequency Methods
| Method | Description |
|---|---|
everySecond() | Run every second |
everyMinute() | Run every minute |
everyTwoMinutes() | Run every 2 minutes |
everyFiveMinutes() | Run every 5 minutes |
everyTenMinutes() | Run every 10 minutes |
everyThirtyMinutes() | Run every 30 minutes |
everyHour() / hourly() | Run every hour |
everyDay() / daily() | Run daily at midnight |
weekly() | Run weekly (Sunday) |
monthly() | Run monthly (1st) |
yearly() / annually() | Run yearly (Jan 1) |
onDays(days[]) | Run on specific days |
at(time) | Run at specific time |
Option Methods
| Method | Description |
|---|---|
setTimeZone(tz) | Set timezone |
withErrorHandler(fn) | Set error handler |
withMaxRuns(n) | Limit executions |
withProtection(fn?) | Prevent overlapping |
withName(name) | Set schedule name |
withContext(ctx) | Pass context data |
withInterval(sec) | Custom interval |
between(start, end) | Limit to date range |
Helper Functions
| Function | Description |
|---|---|
sendAt(cron) | Get next run date |
timeout(cron) | Get ms until next run |
Schedule.gracefulShutdown() | Stop all jobs |