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

MethodDescription
schedule.job(name)Schedule a job
schedule.action(name)Schedule an action
schedule.command(cmd)Schedule a command

Frequency Methods

MethodDescription
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

MethodDescription
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

FunctionDescription
sendAt(cron)Get next run date
timeout(cron)Get ms until next run
Schedule.gracefulShutdown()Stop all jobs