Scheduler
Cron-like periodic task execution — cleanup jobs, report generation, cache pruning, digest emails. Tasks are defined in code with a fluent API and run by a long-lived scheduler process that checks every minute.
Setup
Define your tasks inapp/schedules.ts:
import { Scheduler } from '@strav/queue'
Scheduler.task('cleanup:sessions', async () => {
await db.sql`DELETE FROM "_strav_sessions" WHERE "expires_at" < CURRENT_TIMESTAMP`
}).hourly()
Scheduler.task('reports:daily', async () => {
const report = await generateDailyReport()
await saveReport(report)
}).dailyAt('02:00')
.withoutOverlapping()
No database tables, no DI container — Scheduler is a static registry like Emitter.
Defining tasks
Register a task with a name and handler. Chain a frequency method to set its schedule:
import { Scheduler } from '@strav/queue'
Scheduler.task('task-name', async () => {
// your logic
}).everyFiveMinutes()
The handler can be sync or async. The name is used for logging and overlap tracking.
Frequency methods
Minute-based
.everyMinute() // * * * * *
.everyTwoMinutes() // */2 * * * *
.everyFiveMinutes() // */5 * * * *
.everyTenMinutes() // */10 * * * *
.everyFifteenMinutes() // */15 * * * *
.everyThirtyMinutes() // */30 * * * *
Hourly
.hourly() // at minute 0
.hourlyAt(45) // at minute 45
Daily
.daily() // midnight
.dailyAt('02:30') // at 02:30 UTC
.twiceDaily(8, 20) // at 08:00 and 20:00
Weekly
.weekly() // Sunday at midnight
.weeklyOn('monday', '08:00')
.weeklyOn(5) // Friday at midnight (0=Sun, 1=Mon, ..., 6=Sat)
Day names are case-insensitive and accept full names or abbreviations: monday/mon, tuesday/tue, etc.
Monthly
.monthly() // 1st at midnight
.monthlyOn(15, '09:30') // 15th at 09:30
Raw cron
For complex schedules, use a standard 5-field cron expression:
.cron('*/10 8-17 * * 1-5') // every 10 min, 8am–5pm, weekdays
.cron('0 0 1,15 * *') // 1st and 15th at midnight
Supported syntax: *, exact (5), range (1-5), list (1,3,5), step (*/10), range+step (1-30/5).
Sporadic (human-like)
Run a task at random intervals within a range, simulating non-periodic, human-like behavior — useful for web scraping, polling external APIs, or any task where predictable timing is undesirable:
import { Scheduler, TimeUnit } from '@strav/queue'
// Run every 5–30 minutes at random
Scheduler.task('scrape:prices', async () => {
await scrapePrices()
}).sporadically(5, 30, TimeUnit.Minutes)
// Run every 1–4 hours at random, prevent overlap
Scheduler.task('poll:feed', async () => {
await pollExternalFeed()
}).sporadically(1, 4, TimeUnit.Hours)
.withoutOverlapping()
Available units: TimeUnit.Minutes, TimeUnit.Hours, TimeUnit.Days.
Each time the task fires, the next run is scheduled at a new random delay within the range. The nextRunAt getter exposes the next scheduled time for debugging:
const schedule = Scheduler.task('poll', handler).sporadically(10, 60, TimeUnit.Minutes)
console.log(schedule.nextRunAt) // Date
Options
Overlap prevention
Prevent a task from running if the previous run hasn't finished yet (in-memory, single-process):
Scheduler.task('heavy-report', async () => {
await generateLargeReport() // takes 10+ minutes
}).everyFiveMinutes()
.withoutOverlapping()
If the task is still running at the next tick, it is skipped.
Immediate execution
Execute a task immediately upon registration, then follow the configured schedule:
Scheduler.task('cache:warm', async () => {
await warmApplicationCache()
}).everyFifteenMinutes()
.runImmediately()
Scheduler.task('bootstrap:data', async () => {
await ensureRequiredData()
}).daily()
.runImmediately()
.withoutOverlapping()
The task executes immediately when runImmediately() is called, then runs according to its schedule. Useful for:
- Bootstrap tasks that need to run on deployment
- Cache warming to ensure good performance from start
- Data initialization or health checks
- Any task that should run at startup, then periodically
Errors during immediate execution are caught and logged but won't crash the application.
Running the scheduler
CLI
bun strav schedule
Press Ctrl+C to stop gracefully — the scheduler finishes active tasks before exiting.
How it works
- The runner sleeps until the next minute boundary (
XX:XX:00). - Checks which tasks are due using
Scheduler.due(now). - Executes all due tasks concurrently via
Promise.allSettled— one failing task doesn't block others. - Repeats until stopped.
Errors are logged to stderr but never crash the scheduler process.
Manual execution
Execute any registered task immediately by name, outside the scheduled times:
// Execute a task manually
await Scheduler.runNow('cleanup:sessions')
// Execute multiple tasks
await Scheduler.runNow('reports:daily')
await Scheduler.runNow('cache:prune')
Error handling
The runNow method throws errors for debugging and control flow:
try {
await Scheduler.runNow('nonexistent-task')
} catch (error) {
console.error(error.message)
// "Task "nonexistent-task" not found. Available tasks: cleanup:sessions, reports:daily, cache:prune"
}
try {
await Scheduler.runNow('reports:daily')
} catch (error) {
console.error('Report generation failed:', error.message)
// Handle the failure (retry, alert, etc.)
}
Use cases
- Debugging: Test tasks during development
- Manual operations: Run reports or cleanup on demand
- Emergency tasks: Execute critical tasks outside schedule
- Testing: Verify task behavior in tests
- Deployment: Run tasks after deployments or configuration changes
Cron parser
The built-in parser is also available standalone:
import { parseCron, cronMatches, nextCronDate } from '@strav/queue'
const cron = parseCron('0 2 * * 1') // Mondays at 2am
cronMatches(cron, new Date()) // true/false
const next = nextCronDate(cron, new Date()) // next matching Date (UTC)
Standard cron rule: when both day-of-month and day-of-week are restricted, either match satisfies (OR logic).
Full example
// app/schedules.ts
import { Scheduler, TimeUnit } from '@strav/queue'
import { cache } from '@strav/kernel'
import { Queue } from '@strav/queue'
// Cleanup expired sessions every hour
Scheduler.task('cleanup:sessions', async () => {
const result = await db.sql`
DELETE FROM "_strav_sessions" WHERE "expires_at" < CURRENT_TIMESTAMP
`
console.log(`Cleaned ${result.count} expired sessions`)
}).hourly()
// Generate reports at 2am, prevent overlap
Scheduler.task('reports:daily', async () => {
const report = await generateDailyReport()
await saveReport(report)
}).dailyAt('02:00')
.withoutOverlapping()
// Prune cache every 6 hours
Scheduler.task('cache:prune', async () => {
await cache.flush()
}).cron('0 */6 * * *')
// Send weekly digest on Monday mornings
Scheduler.task('emails:digest', async () => {
const users = await User.where('digest_enabled', true).all()
for (const user of users) {
await Queue.push('send-digest', { userId: user.id })
}
}).weeklyOn('monday', '08:00')
// Scrape competitor prices at random intervals (human-like)
Scheduler.task('scrape:competitors', async () => {
await scrapeCompetitorPrices()
}).sporadically(10, 45, TimeUnit.Minutes)
.withoutOverlapping()
// Warm cache immediately on startup, then every 4 hours
Scheduler.task('cache:warm', async () => {
await cache.set('app:config', await loadConfiguration())
await cache.set('app:features', await loadFeatureFlags())
console.log('Application cache warmed')
}).cron('0 */4 * * *')
.runImmediately()
// Bootstrap essential data on startup
Scheduler.task('bootstrap:essential', async () => {
await ensureDefaultUsers()
await ensureRequiredSettings()
}).daily()
.runImmediately()
.withoutOverlapping()
Testing
UseScheduler.reset() in your test teardown:
import { afterEach } from 'bun:test'
import { Scheduler } from '@strav/queue'
afterEach(() => {
Scheduler.reset()
})
Test task scheduling without running the scheduler loop:
import { Scheduler } from '@strav/queue'
Scheduler.task('test', handler).dailyAt('02:00')
// Check if due at a specific time
const due = Scheduler.due(new Date('2024-06-15T02:00:00Z'))
expect(due).toHaveLength(1)
expect(due[0].name).toBe('test')
// Test manual execution
await Scheduler.runNow('test')
// Test task behavior
let executed = false
Scheduler.task('test-execution', () => {
executed = true
}).daily()
await Scheduler.runNow('test-execution')
expect(executed).toBe(true)
// Test error handling
try {
await Scheduler.runNow('nonexistent')
throw new Error('Should have thrown')
} catch (error) {
expect(error.message).toContain('not found')
}