Task Scheduling Guide
Guren provides a fluent API for defining scheduled tasks within your application. Instead of managing multiple cron entries, you can define your entire task schedule in code.
Task Scheduling Guide
Guren provides a fluent API for defining scheduled tasks within your application. Instead of managing multiple cron entries, you can define your entire task schedule in code.
The standard vNext path is: import scheduling APIs from @guren/core, register schedules centrally, and keep feature code focused on the jobs or commands being scheduled.
Core Concepts
- Scheduler – Manages and runs scheduled tasks at the appropriate times.
- Schedule – Builder for defining tasks with a fluent API.
- ScheduledTask – Individual task with its schedule and configuration.
- Cron Expression – Standard cron syntax for defining when tasks run.
Basic Usage
Quick Start
import { Scheduler } from '@guren/core'
const scheduler = new Scheduler()
scheduler.schedule((schedule) => {
// Run a callback every day at 3 AM
schedule.call(async () => {
await cleanupOldSessions()
}).daily().at('03:00').name('cleanup-sessions')
})
scheduler.start()
Running the Scheduler
Start the scheduler in your application bootstrap:
// app.ts
import { Scheduler } from '@guren/core'
const scheduler = new Scheduler({
timezone: 'Asia/Tokyo',
checkInterval: 60000, // Check every 60 seconds
logger: console.log,
})
// Define schedules
scheduler.schedule((schedule) => {
schedule.call(() => console.log('Hello!')).everyMinute()
})
// Start when app boots
scheduler.start()
// Stop on shutdown
process.on('SIGTERM', () => {
scheduler.stop()
})
On Serverless Runtimes
scheduler.start() needs a long-lived process, which neither Cloudflare Workers nor AWS Lambda has. There the platform's own scheduler supplies the tick and the app only registers tasks:
- Cloudflare Workers — the worker
guren cloudflare:buildgenerates exports ascheduledhandler; atriggers.cronsentry inwrangler.jsoncdrives it. See Cloudflare Workers Deployment. - AWS Lambda —
createScheduleHandler(scheduler)from@guren/core/lambda, wired to an EventBridge rule. See Serverless.
Each firing runs only the tasks due at that moment, so the platform trigger must be at least as frequent as your finest task. preventOverlapping() and onOneServer() are in-memory flags on the task, so neither carries across firings on a runtime that does not keep the process alive. schedule.command() shells out through node:child_process and does not work on Workers — use schedule.call() or schedule.job() there.
Defining Schedules
Callbacks
scheduler.schedule((schedule) => {
schedule.call(async () => {
// Your task logic
await sendDailyReports()
}).daily().at('09:00')
})
Jobs
Dispatch queued jobs on a schedule:
import { SendWeeklyDigestJob } from '@/app/Jobs/SendWeeklyDigestJob'
scheduler.schedule((schedule) => {
schedule.job(SendWeeklyDigestJob, { userId: 'all' })
.weekly()
.sundays()
.at('09:00')
})
Shell Commands
scheduler.schedule((schedule) => {
schedule.command('bunx guren db:backup')
.daily()
.at('02:00')
.name('database-backup')
})
Frequency Options
Minutes
schedule.call(task).everyMinute() // Every minute
schedule.call(task).everyTwoMinutes() // Every 2 minutes
schedule.call(task).everyThreeMinutes() // Every 3 minutes
schedule.call(task).everyFourMinutes() // Every 4 minutes
schedule.call(task).everyFiveMinutes() // Every 5 minutes
schedule.call(task).everyTenMinutes() // Every 10 minutes
schedule.call(task).everyFifteenMinutes()// Every 15 minutes
schedule.call(task).everyThirtyMinutes() // Every 30 minutes
Hours
schedule.call(task).hourly() // Every hour at :00
schedule.call(task).hourlyAt(15) // Every hour at :15
schedule.call(task).everyTwoHours() // Every 2 hours
schedule.call(task).everyThreeHours() // Every 3 hours
schedule.call(task).everyFourHours() // Every 4 hours
schedule.call(task).everySixHours() // Every 6 hours
Days
schedule.call(task).daily() // Every day at midnight
schedule.call(task).dailyAt('13:00') // Every day at 1 PM
schedule.call(task).at('13:00') // Alias for dailyAt
schedule.call(task).twiceDaily(1, 13) // At 1 AM and 1 PM
Weeks
schedule.call(task).weekly() // Every Sunday at midnight
schedule.call(task).weeklyOn(1, '08:00') // Every Monday at 8 AM
// Day shortcuts
schedule.call(task).daily().sundays()
schedule.call(task).daily().mondays()
schedule.call(task).daily().tuesdays()
schedule.call(task).daily().wednesdays()
schedule.call(task).daily().thursdays()
schedule.call(task).daily().fridays()
schedule.call(task).daily().saturdays()
// Weekdays and weekends
schedule.call(task).daily().weekdays() // Monday-Friday
schedule.call(task).daily().weekends() // Saturday-Sunday
Months and Years
schedule.call(task).monthly() // 1st of month at midnight
schedule.call(task).monthlyOn(15, '09:00') // 15th at 9 AM
schedule.call(task).lastDayOfMonth('18:00') // Last day at 6 PM
schedule.call(task).quarterly() // Jan, Apr, Jul, Oct 1st
schedule.call(task).yearly() // Jan 1st at midnight
schedule.call(task).yearlyOn(6, 15, '12:00') // June 15th at noon
Custom Cron
// Standard cron format: minute hour day-of-month month day-of-week
schedule.call(task).cron('0 */2 * * *') // Every 2 hours
schedule.call(task).cron('30 9 * * 1-5') // 9:30 AM weekdays
schedule.call(task).cron('0 0 1,15 * *') // 1st and 15th at midnight
Task Configuration
Task Names
schedule.call(sendReports)
.daily()
.name('send-daily-reports') // Unique identifier for the task
Timezones
schedule.call(task)
.daily()
.at('09:00')
.tz('Asia/Tokyo') // Run at 9 AM Tokyo time
// Or using setTimezone
schedule.call(task)
.daily()
.setTimezone('America/New_York')
Preventing Overlaps
Prevent a task from running if a previous instance is still executing:
schedule.call(longRunningTask)
.everyMinute()
.preventOverlapping() // Skip if previous run hasn't finished
// With expiration (auto-unlock after 10 minutes)
schedule.call(task)
.everyMinute()
.preventOverlapping(600000) // 10 minutes in ms
Running on One Server
For multi-server deployments, ensure task runs on only one server:
schedule.call(task)
.daily()
.runOnOneServer() // Requires distributed lock (Redis)
Conditional Execution
// Run only if condition is true
schedule.call(task)
.daily()
.when(() => process.env.NODE_ENV === 'production')
// Skip if condition is true
schedule.call(task)
.daily()
.skip(() => isMaintenanceMode())
Lifecycle Hooks
schedule.call(sendEmails)
.daily()
.at('09:00')
.before(() => console.log('Starting email send...'))
.after(() => console.log('Email send complete'))
.onSuccess(() => metrics.increment('emails.sent'))
.onFailure((error) => {
alerting.notify('Email send failed', error)
})
Scheduler API
const scheduler = new Scheduler()
// Define tasks
scheduler.schedule((schedule) => { ... })
// Add pre-built task
scheduler.addTask(scheduledTask)
// Start/stop
scheduler.start()
scheduler.stop()
// Check status
scheduler.getIsRunning()
// Get tasks
scheduler.getTasks() // All tasks
scheduler.getDueTasks() // Tasks due now
scheduler.getTask('task-name') // Task by name
scheduler.count() // Number of tasks
// Manage tasks
scheduler.removeTask('task-name') // Remove by name
scheduler.clear() // Remove all tasks
// Manual execution
await scheduler.runDueTasks() // Run all due tasks now
CLI Integration
The ticking scheduler lives inside your application — scheduler.start(), above.
The CLI covers the other half: seeing what is registered, and driving a run from
outside the process, which is what a system cron or a platform trigger calls.
# List scheduled tasks
bunx guren schedule:list
bunx guren schedule:list --json
# Run whichever tasks are due now
bunx guren schedule:run
# Run one task immediately, due or not
bunx guren schedule:run --task cleanup-sessions --force
Making tasks visible to the CLI
schedule:list and schedule:run do not boot your application. They load the
schedule kernel directly, probing app/Console/Kernel.ts (and the lowercase and
src/ variants), or the path given to --kernel. Tasks declared anywhere else
are invisible to both commands, however reliably they run under
scheduler.start().
Two export shapes are recognized, each under its own naming convention.
A registrar is the shape most app code already has: a provider builds the scheduler and hands it over, and the CLI supplies one of its own.
// app/Console/Kernel.ts
import type { Scheduler } from '@guren/core'
export function registerSchedules(scheduler: Scheduler): void {
scheduler.schedule((schedule) => {
schedule.call(warmCache).hourly().name('warm-cache')
})
}
Name it register…Schedules — registerSchedules, registerBillingSchedules
— or make it the default export. A kernel may export several, and they all
receive the same scheduler. The convention is what keeps the CLI from calling
every helper the file happens to export; a registrar named anything else is
reported as unrecognized rather than silently skipped.
A kernel factory takes nothing and returns the Schedule it built. It is
recognized as scheduleTasksKernel, schedule, defineSchedule, or the default
export, and is what bunx guren add schedule scaffolds.
// app/Console/Kernel.ts
import { Schedule } from '@guren/core'
export function scheduleTasksKernel(): Schedule {
const schedule = new Schedule()
schedule.call(warmCache).hourly().name('warm-cache')
return schedule
}
A factory declares the tasks but runs nothing on its own — a provider still has to feed them to the scheduler it binds:
for (const task of scheduleTasksKernel().buildTasks()) scheduler.addTask(task)
Prefer the registrar in application code: it is the same Scheduler API the rest
of this guide teaches, and the tasks reach the running scheduler without a second
wiring step.
Either way, resolve services inside the task callback rather than while the kernel is being built — the CLI reads this file without booting your app, so a container lookup at build time has nothing to resolve:
schedule.call(() => getContainer().make<SessionManager>('session').pruneExpired()).hourly()
A kernel that exists but matches neither shape, or that throws while loading, is reported as such and exits non-zero — it is not the same state as an app that has not scheduled anything yet.
Testing
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import { Scheduler, Schedule } from '@guren/core'
describe('Scheduling', () => {
let scheduler: Scheduler
beforeEach(() => {
scheduler = new Scheduler()
})
afterEach(() => {
scheduler.stop()
})
test('schedules daily task', () => {
scheduler.schedule((schedule) => {
schedule.call(() => {}).daily().at('09:00').name('test-task')
})
expect(scheduler.count()).toBe(1)
expect(scheduler.getTask('test-task')).toBeDefined()
})
test('identifies due tasks', () => {
const mockTask = mock(() => {})
scheduler.schedule((schedule) => {
schedule.call(mockTask).everyMinute()
})
const dueTasks = scheduler.getDueTasks(new Date())
expect(dueTasks.length).toBeGreaterThan(0)
})
test('runs due tasks', async () => {
let executed = false
scheduler.schedule((schedule) => {
schedule.call(() => { executed = true }).everyMinute()
})
await scheduler.runDueTasks()
expect(executed).toBe(true)
})
test('respects when condition', async () => {
let executed = false
scheduler.schedule((schedule) => {
schedule.call(() => { executed = true })
.everyMinute()
.when(() => false) // Never run
})
await scheduler.runDueTasks()
expect(executed).toBe(false)
})
})
Best Practices
Name your tasks: Always use
.name()for easier debugging and management.Use appropriate frequencies: Don't schedule tasks more often than needed.
Set timezones explicitly: Avoid ambiguity by setting timezone for time-sensitive tasks.
Prevent overlapping for long tasks: Use
.preventOverlapping()for tasks that may take longer than their interval.Handle failures gracefully: Use
.onFailure()to log errors and send alerts.Test schedules: Write tests to verify your task scheduling logic.
Monitor task execution: Log task runs and track success/failure metrics.
Use jobs for heavy work: Dispatch jobs instead of running heavy tasks directly in the scheduler.