| name | stacks-queue |
| description | Use when working with job queues in a Stacks application — creating jobs, dispatching, workers, batches, failed jobs, queue events, health checks, testing, Redis/database/sync drivers, rate limiting, or scheduled jobs. Covers @stacksjs/queue, config/queue.ts, and app/Jobs/. |
| license | MIT |
| compatibility | Bun >= 1.3.0, TypeScript |
| allowed-tools | Read Edit Write Bash Grep Glob |
Stacks Queue System
Key Paths
- Queue package:
storage/framework/core/queue/src/
- Configuration:
config/queue.ts
- Application jobs:
app/Jobs/
- Job model:
storage/framework/defaults/app/Models/Job.ts
- Failed job model:
storage/framework/defaults/app/Models/FailedJob.ts
Source Files
queue/src/
├── action.ts # Job class with dispatch/dispatchIf/dispatchAfter/dispatchNow
├── job.ts # JobBuilder fluent API + job() helper + jobBatch() + runJob()
├── discovery.ts # Job auto-discovery from app/Jobs/ via Bun.Glob
├── scheduler.ts # Cron-based job scheduling with overlap prevention
├── worker.ts # Queue worker (database polling & Redis processing)
├── batch.ts # Job batching (PendingBatch, DispatchedBatch, Batch)
├── events.ts # QueueEvents emitter, QueueMetrics, WorkerTracker
├── health.ts # Queue health checks (checkQueueHealth, HTTP handler)
├── notifications.ts # Failed job notifications (email/slack/discord/webhook)
├── testing.ts # FakeQueue, QueueTester, runJob, expectJobToFail
├── utils.ts # storeJob helper
├── drivers/redis.ts # RedisQueue (bun-queue wrapper), StacksQueueManager
└── index.ts # Re-exports everything
Job Class (action.ts)
export class Job {
name: string
description: string
action?: string | Function
handle?: Function
queue?: string
rate?: string
tries?: number
timeout?: number
backoff?: number | number[]
backoffConfig?: { strategy, initialDelay, factor, maxDelay, jitter }
enabled?: boolean
async dispatch(payload?): Promise<void>
async dispatchIf(condition: boolean, payload?): Promise<void>
async dispatchUnless(condition: boolean, payload?): Promise<void>
async (: , payload?): <>
(payload?): <>
}
Dispatch Routing
- Checks
isFaked() -- if testing, dispatches to FakeQueue
sync driver: calls dispatchNow() (immediate execution)
redis driver: creates RedisQueue, calls queue.add() with bun-queue options
database driver: inserts into jobs table with JSON payload
- Fallback:
dispatchNow()
dispatchNow() Execution Order
- If
handle is a function: await this.handle(payload)
- If
action is a string: await runAction(this.action) via @stacksjs/actions
- If
action is a function: await this.action()
- Otherwise: throws error
JobBuilder Fluent API (job.ts)
import { job } from '@stacksjs/queue'
await job('SendWelcomeEmail', { email, name })
.onQueue('emails')
.delay(60)
.tries(5)
.timeout(30)
.backoff([10, 30, 60])
.withContext({ userId })
.dispatch()
await job('ProcessOrder', data).dispatchIf(isValid)
await job('ProcessOrder', data).dispatchUnless(isDuplicate)
await job('SendNotification', data).dispatchNow()
The job() helper loads job modules from app/Jobs/{name}.ts via runJob().
runJob(name, options)
Dynamically imports app/Jobs/{name}.ts and executes:
jobConfig.handle(payload) if handle is a function
runAction(jobConfig.action) if action is a string
jobConfig.action() if action is a function
jobConfig(payload, context) if default export is a function
jobBatch() Helper
import { jobBatch } from '@stacksjs/queue'
const batch = await jobBatch([SendEmail, ProcessOrder])
.name('Onboard User')
.allowFailures()
.then(async (b) => console.log('All done!'))
.dispatch()
Job Batching (batch.ts)
PendingBatch (not yet dispatched)
const batch = Batch.create([
{ job: SendEmail, payload: { to: 'a@b.com' } },
{ job: ProcessImage, payload: { id: 1 } },
])
.name('welcome-batch')
.onQueue('default')
.allowFailures()
.then(async (batch) => {})
.catch(async (batch, error) => {})
.finally(async (batch) => {})
.progress(async (batch) => {})
const dispatched = await batch.dispatch()
DispatchedBatch (inspectable)
const b = await Batch.find(batchId)
await b.progress()
await b.pendingJobs()
await b.failedJobs()
await b.completedJobs()
await b.failedJobIds()
await b.finished()
await b.cancelled()
await b.hasFailures()
await b.cancel()
await b.add([moreJobs])
await b.delete()
await b.fresh()
Batch Static Methods
Batch.create(jobs): PendingBatch
await Batch.find(id): DispatchedBatch | null
await Batch.all(): DispatchedBatch[]
await Batch.prune(olderThanHours = 24): number
Batch Storage
- Database:
job_batches table with columns: id, name, total_jobs, pending_jobs, failed_jobs, failed_job_ids (JSON), options (JSON), cancelled_at, created_at, finished_at
- Redis: hash at
stacks:batch:{id}, set index at stacks:batches
- Callbacks stored in in-memory
Map (not serializable -- only work in dispatching process)
Batch Lifecycle
- On job completion: decrements
pending_jobs, fires progress callbacks. When pending_jobs === 0: fires then + finally callbacks, emits batch:completed
- On job failure: increments
failed_jobs, appends to failed_job_ids. If allowFailures is false: cancels batch immediately. Fires catch callbacks, emits batch:failed
- Worker checks
_batchId in payload to skip cancelled batch jobs
Job Discovery (discovery.ts)
const jobs = await discoverJobs()
const job = getJob('SendWelcomeEmail')
const scheduled = getScheduledJobs()
const all = getAllJobs()
await executeJob('SendWelcomeEmail', payload)
Skips .test., .spec., and index files. Supports both class-based (has handle on prototype) and function-based (object with handle method) jobs.
JobRegistry
Global singleton jobRegistry:
register(job), get(name), all(), byQueue(queue), scheduled(), has(name), clear()
BackoffConfig
interface BackoffConfig {
strategy?: 'fixed' | 'exponential' | 'linear'
initialDelay?: number
factor?: number
maxDelay?: number
jitter?: { enabled?: boolean, factor?: number, minDelay?: number, maxDelay?: number }
}
Job Scheduler (scheduler.ts)
Cron-based scheduling with 1-minute check interval.
await startScheduler({ checkInterval: 60000, timezone: 'UTC', preventOverlapping: true })
await stopScheduler()
isSchedulerRunning()
getSchedulerStatus()
getRegisteredJobs()
await triggerJob('SendReport')
Cron Support
- Standard 5-part cron:
minute hour day month dayOfWeek
- 6-part cron (with seconds): seconds are stripped, runs at minute granularity
- Predefined:
@yearly, @monthly, @weekly, @daily, @hourly, @midnight
- Every expressions:
Every.minute, Every.fiveMinutes, Every.hour, Every.day, etc.
- Supports lists (
1,2,3), ranges (1-5), steps (*/5, 1-5/2), wildcards (*)
Overlap Prevention
- Global
preventOverlapping: true (default) skips if job is still running
- Per-job
withoutOverlapping: true on job config
Queue Worker (worker.ts)
await startProcessor('default', { concurrency: 5 })
await stopProcessor()
await executeFailedJobs()
await retryFailedJob(id)
getActiveJobCount()
isWorkerRunning()
Database Worker Details
- Polls
jobs table every 1 second (sleep(1000))
- Refreshes queue list every 10 seconds via
getAllQueues() (SELECT DISTINCT queue FROM jobs)
- Atomic claim: SELECT pending job + UPDATE with CAS pattern (
WHERE reserved_at IS NULL)
- Increments
attempts, sets reserved_at to current timestamp
- If
numUpdatedRows === 0, another worker claimed it -- tries next
- On success: DELETE from
jobs, emit job:completed
- On failure: compare
attempts vs maxAttempts (from payload options.tries, default 1)
- Exceeded: move to
failed_jobs table (uuid, connection, queue, payload, exception, failed_at), emit job:failed
- Retrying: release job by setting
reserved_at = null, available_at = now + backoff, emit job:retrying
- Backoff: uses
options.backoff from payload -- array (indexed by attempt), number (fixed), or 30s default
- Worker ID:
worker-{pid}-{timestamp}
- Handles
SIGINT / SIGTERM for graceful shutdown
Redis Worker Details
- Uses
RedisQueue.process(concurrency, handler) from bun-queue
- Handler receives
BunJob objects
- Throws on failure for bun-queue's built-in retry handling
- Keep-alive loop: polls
sleep(1000) until workerRunning === false
- Closes queue on shutdown
Job Payload Format (database)
{
"jobName": "SendWelcomeEmail",
"payload": { "email": "user@example.com" },
"options": { "queue": "emails", "tries": 3, "timeout": 30, "backoff": [10, 30, 60] }
}
Queue Events (events.ts)
QueueEvents Class
Custom event emitter (not mitt-based) with Map<QueueEventType, Set<Handler>> + wildcard Set.
const events = getQueueEvents()
const unsub = events.on('job:failed', handler)
const unsub2 = events.onAny((event, payload) => { ... })
events.once('job:completed', handler)
await events.emit('job:completed', payload)
events.off('job:completed')
events.removeAllListeners()
Convenience Functions
const unsub = onQueueEvent('job:failed', (payload) => { ... })
const unsub2 = onQueueEvent('*', (event, payload) => { ... })
await emitQueueEvent('job:completed', { jobId, result, duration })
Event Types
'job:added' | 'job:processing' | 'job:completed' | 'job:failed' | 'job:retrying' | 'job:stalled' | 'job:progress' | 'queue:paused' | 'queue:resumed' | 'queue:error' | 'worker:started' | 'worker:stopped' | 'batch:added' | 'batch:completed' | 'batch:failed'
QueueEventPayload
interface QueueEventPayload {
jobId?: string, queueName?: string, jobName?: string, data?: any,
result?: any, error?: Error, progress?: number, timestamp: number,
attemptsMade?: number, duration?: number
}
Event-Aware Wrapper
const wrappedHandler = withEvents('emails', originalHandler)
OnQueueEvent Decorator
class MyHandler {
@OnQueueEvent('job:failed')
handleFailed(payload: QueueEventPayload) { ... }
}
QueueMetrics
const metrics = getGlobalMetrics()
metrics.getThroughputPerMinute()
metrics.getAverageProcessingTime()
metrics.getMetrics()
metrics.reset()
metrics.stop()
Tracks last 1000 completions and last 100 errors in sliding windows.
WorkerTracker
const tracker = getWorkerTracker()
tracker.register(id, queue)
tracker.markActive(id) / tracker.markIdle(id)
tracker.recordCompletion(id) / tracker.recordFailure(id)
tracker.unregister(id)
tracker.getAll(): TrackedWorker[]
tracker.clear()
interface TrackedWorker {
id: string, status: 'active' | 'idle' | 'stopped', queue: string,
processedCount: number, failedCount: number,
lastActivityAt: string, startedAt: string
}
Queue Health (health.ts)
const result = await checkQueueHealth({
maxPendingWarning: 1000, maxPendingCritical: 5000,
maxFailedWarning: 10, maxFailedCritical: 100,
maxJobAgeWarning: 3600, maxJobAgeCritical: 86400,
maxErrorRateWarning: 0.1, maxErrorRateCritical: 0.5,
queues?: ['default', 'emails']
})
- Queries
jobs and failed_jobs tables directly
- Groups by queue name, calculates pending/processing/delayed/failed counts
- Oldest job age calculated from
created_at
- Error rate = totalFailed / totalJobs
- Worker statuses from
getWorkerTracker().getAll()
- Metrics from
getGlobalMetrics()
- Returns real
TrackedWorker data even if DB query fails
const healthy = await isQueueHealthy()
const handler = createHealthCheckHandler()
Failed Job Notifications (notifications.ts)
configureFailedJobNotifications({
channels: ['email', 'slack', 'discord', 'webhook', 'log'],
email: { to: ['admin@example.com'], from?: string, subject?: string },
slack: { webhookUrl: '...', channel?: '#alerts', username?: 'Queue Monitor', iconEmoji?: ':warning:' },
discord: { webhookUrl: '...', username?: 'Queue Monitor', avatarUrl?: string },
webhook: { url: '...', headers?: Record<string,string>, secret?: 'hmac-key' },
rateLimit: 100,
batch: true,
batchInterval: 60000,
filter: (job) => job.queue !== 'low-priority',
})
- Email: uses
@stacksjs/email (mail.send()) with HTML table format
- Slack: sends Block Kit payload (header + sections, max 10 jobs per message)
- Discord: sends embeds (red color, max 10 per message)
- Webhook: JSON payload with HMAC SHA-256 signature in
X-Signature header (via crypto.subtle)
- Log: writes to application log via
@stacksjs/logging
- Rate limiter resets every 3,600,000ms (1 hour)
- Batch: accumulates jobs, flushes on interval via
setTimeout
const notifier = getFailedJobNotifier()
await notifyJobFailed(jobInfo)
Queue Testing (testing.ts)
import { fake, restore, runJob as runTestJob, expectJobToFail } from '@stacksjs/queue'
const fakeQueue = fake()
await job('SendEmail', data).dispatch()
await SendWelcomeEmail.dispatch(data)
fakeQueue.assertDispatched('SendEmail')
fakeQueue.assertDispatched('SendEmail', (j) => j.data.to === 'test@example.com')
fakeQueue.assertNotDispatched('ProcessPayment')
fakeQueue.assertDispatchedTimes('SendEmail', 3)
fakeQueue.assertNothingDispatched()
fakeQueue.assertPushed('Reminder')
fakeQueue.assertPushedWithDelay('Reminder', 3600)
fakeQueue.assertPushedOn('emails', 'SendEmail')
const result = await runTestJob({ handle: async (data) => 'ok' }, testData)
error = (, data, )
()
QueueTester Class
const tester = createQueueTester()
tester.dispatch('Job', data).assertDispatched('Job').reset()
tester.cleanup()
FakeQueue Internals
dispatchedJobs[] -- recorded dispatches
pushedJobs[] -- recorded delayed pushes
processedJobs[] -- simulated processing results
failedJobs[] -- simulated failures
Redis Driver (drivers/redis.ts)
Wraps bun-queue's Queue class with Stacks-compatible API.
const queue = new RedisQueue<T>('emails', redisConfig)
await queue.add(data, options)
queue.process(concurrency, handler)
await queue.getJob(jobId)
await queue.getJobs('waiting' | 'active' | 'completed' | 'failed' | 'delayed')
await queue.getJobCounts()
await queue.removeJob(jobId)
await queue.pause() / queue.resume()
await queue.empty() / queue.close()
await queue.getMetrics()
await queue.ping()
await queue.scheduleCron({ cron, data, tz?, name? })
await queue.unscheduleCron(jobId)
await queue.getDeadLetterJobs()
await queue.republishDeadLetterJob(jobId)
await queue.clearDeadLetterQueue()
await queue.bulkRemove(jobIds):
queue.()
queue.():
queue.(): <T>
queue.(event, handler)
StacksQueueManager
const manager = new StacksQueueManager(connectionConfigs)
manager.queue('emails')
manager.setDefaultConnection('high-priority')
await manager.closeAll()
RedisQueue Events
jobAdded, jobCompleted, jobFailed, jobProgress, jobActive, jobStalled, jobDelayed, ready, error
bun-queue Re-exports
batch, chain, dispatch, dispatchAfter, dispatchSync, getQueueManager, setQueueManager, QueueManager
Creating a Job
import { Job } from '@stacksjs/queue'
export default new Job({
name: 'SendWelcomeEmail',
description: 'Send welcome email to new user',
queue: 'emails',
tries: 3,
backoff: [10, 30, 60],
timeout: 30,
async handle(payload: { email: string; name: string }) {
console.log(`Sending to ${payload.email}`)
return { sent: true }
}
})
Dispatching
import SendWelcomeEmail from '~/app/Jobs/SendWelcomeEmail'
await SendWelcomeEmail.dispatch({ email, name })
await SendWelcomeEmail.dispatchIf(isNewUser, { email, name })
await SendWelcomeEmail.dispatchUnless(isDuplicate, { email, name })
await SendWelcomeEmail.dispatchAfter(60, { email, name })
await SendWelcomeEmail.dispatchNow({ email, name })
config/queue.ts
{
default: 'sync',
connections: {
sync: { driver: 'sync' },
database: { driver: 'database', table: 'jobs', queue: 'default', retryAfter: 90 },
redis: {
driver: 'redis',
redis: { url, host: 'localhost', port: 6379, password, db: 0 },
prefix: 'stacks:queue',
concurrency: 5,
logLevel: 'info',
distributedLock: true,
stalledJobCheckInterval: 30000,
maxStalledJobRetries: 3,
limiter: { max: 100, duration: 1000 },
metrics: { enabled: false, collectInterval: 10000 },
defaultDeadLetterOptions: { enabled: true, maxRetries: 3, : },
: { enabled, maxWorkersPerInstance, jobsPerWorker, : { : , : } },
: { : , : , : , : { : , : } }
},
: { : , key, secret, prefix, suffix, : , : },
: { : , : }
},
: { : , : , : },
: { : , : },
: { : , : }
}
Gotchas
- Default driver is
sync -- jobs run immediately in the same process (no background processing)
- Database driver polls every 1 second, refreshes queue list every 10 seconds
- Atomic claim prevents race conditions: SELECT + UPDATE WHERE reserved_at IS NULL (CAS pattern)
- Backoff supports: fixed number, array of delays (indexed by attempt), or BackoffConfig object with strategy
- Job batches store callbacks in-memory (
Map) -- callbacks only work in the dispatching process, not across restarts
- Redis driver wraps
bun-queue -- uses bun-queue Queue class for actual queue management
- Failed job notifications use HMAC SHA-256 (via Web Crypto API) for webhook signatures
- Health check queries
jobs and failed_jobs tables directly for real counts
- Rate limiter for notifications resets hourly (3,600,000ms)
- Testing:
fake() sets global singleton, restore() clears it -- affects ALL code in the process
- Queue events use a custom
QueueEvents class (Map + Set), not mitt
- Metrics track last 1000 completions and 100 errors in sliding windows
- Worker ID format:
worker-{pid}-{timestamp}
- The
QUEUE_DRIVER env var controls which driver is used (reads via @stacksjs/env)
dispatchAfter() on sync driver uses setTimeout to delay then executes immediately
- Failed jobs table:
uuid, connection ('database'), queue, payload, exception (stack trace), failed_at
process.on('unhandledRejection') and process.on('uncaughtException') are set at module load in worker.ts