Scheduler Package

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

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)

// 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

// Pass context to scheduled task
schedule.job('ProcessBatch')
  .hourly()
  .withContext({
    batchSize: 100,
    retryCount: 3
  })

Interval

// Custom interval in seconds
schedule.job('CustomInterval')
  .everyMinute()
  .withInterval(45) // Every 45 seconds

Date Range

// 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 -v

# Run specific scheduled task immediately
buddy schedule:test SendDailyReport

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

Edge Cases

Handling Missed Runs

// If server was down during scheduled time,
// the job runs immediately on startup if within catch-up window
schedule.job('ImportantTask')
  .daily()
  .withCatchUp(true) // Default is false

// Note: Catch-up may cause multiple runs if server was down for days

Long-Running Tasks

// For tasks that may exceed schedule interval
schedule.job('SlowTask')
  .everyMinute()
  .withProtection() // Prevents overlapping
  .withTimeout(300000) // 5 minute timeout

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