Jobs
Introduction
Section titled “Introduction”Some tasks don’t belong in the request-response cycle of a slash command. Generating a moderation report across thousands of cases, syncing bans across multiple servers, cleaning up expired mutes — these are things that should happen in the background, not while a user stares at “Bot is thinking…”
@warden/jobs gives you persistent, reliable background processing with scheduled task support. Jobs survive bot restarts, can be retried on failure, and are prioritized, delayed, and monitored.
Installation
Section titled “Installation”pnpm add @warden/jobsThen register the plugin in your bootstrap file:
import {Bot} from '@warden/core';import {JobsServiceProvider} from '@warden/jobs';
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts') .jobs('./src/jobs/**/*.ts')) .plugins([JobsServiceProvider]) .intents(i => i.defaults()) .partials(p => p.defaults()) .start();The plugin ships with three built-in adapters so you can pick the right backend for your environment. The default is in-memory — fine for development; for production you’ll want BullMQ or Database.
Adapters
Section titled “Adapters”Pick your adapter in config/jobs.ts:
In-process queue with zero external dependencies. Jobs process immediately in the same process. Ideal for development and testing where you don’t want to run Redis.
import {Config} from '@warden/core';import {JobsServiceProvider, MemoryAdapter} from '@warden/jobs';
export default Config.define(JobsServiceProvider, { adapter: new MemoryAdapter(),});Production-grade, persistent, Redis-backed. Supports priorities, retries with backoff, delayed jobs, and monitoring.
import {Config} from '@warden/core';import {JobsServiceProvider, BullMQAdapter} from '@warden/jobs';
export default Config.define(JobsServiceProvider, { adapter: new BullMQAdapter(),});Persistent job processing using your existing drizzle connection via @warden/drizzle. No Redis needed — if you already have a database, you have a job queue.
import {Config} from '@warden/core';import {JobsServiceProvider, DatabaseAdapter} from '@warden/jobs';
export default Config.define(JobsServiceProvider, { adapter: new DatabaseAdapter(),});| Adapter | Persistent | Shared across instances | External dependency |
|---|---|---|---|
MemoryAdapter | No | No | None |
BullMQAdapter | Yes | Yes | Redis server |
DatabaseAdapter | Yes | Yes | Database (already have it) |
The adapter field accepts any class that implements the QueueAdapter interface, so you’re free to write your own or use community adapters — see Building your own plugin for the adapter pattern.
Workers
Section titled “Workers”A worker is a class that processes a specific type of background job. Define one with the @worker() decorator:
import {worker, Worker} from '@warden/jobs';
@worker({name: 'generate-mod-report'})export default class GenerateModReportWorker implements Worker { constructor(private caseService: CaseService, private logger: Logger) {}
public async process(payload: {guildId: string; requestedBy: string}): Promise<void> { this.logger.info('Generating mod report', {guildId: payload.guildId});
const cases = await this.caseService.getAllCases(payload.guildId); const report = await this.caseService.generateReport(cases);
await this.caseService.saveReport(payload.guildId, report); this.logger.info('Mod report generated', {guildId: payload.guildId, caseCount: cases.length}); }}Workers are picked up by the .jobs(...) discovery bucket, just like other decorated classes. They have full dependency injection support — inject any service you need. The name must match what you use when dispatching the job.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique job name, used when dispatching |
Dispatching Jobs
Section titled “Dispatching Jobs”To push a job onto the queue, inject the Queue service and call dispatch():
import {command, Command, CommandInteraction, Params, reply} from '@warden/core';import {Queue} from '@warden/jobs';
@command({parent: 'mod', name: 'stats', description: 'Generate moderation statistics'})export default class ModStatsCommand extends Subcommand { constructor(private queue: Queue) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { await this.queue.dispatch('generate-mod-report', { guildId: interaction.guildId!, requestedBy: interaction.user.id, });
await interaction.followUp({embeds: [ Embed.success('Your moderation report is being generated. You\'ll be notified when it\'s ready.') ]}); }}The command returns instantly, and the report generation happens in the background. No timeout worries, no “Bot is thinking…” spinner holding everyone up.
Dispatch options
Section titled “Dispatch options”The dispatch() method accepts an optional third argument for fine-grained control:
await this.queue.dispatch('generate-mod-report', payload, { delay: 5000, // wait 5 seconds before processing attempts: 3, // retry up to 3 times on failure backoff: 'exponential', // exponential backoff between retries priority: 1, // higher priority jobs are processed first});| Option | Type | Default | Description |
|---|---|---|---|
delay | number | 0 | Milliseconds to wait before the job becomes available |
attempts | number | 1 | Maximum number of processing attempts |
backoff | 'fixed' | 'exponential' | 'fixed' | Backoff strategy between retries |
priority | number | 0 | Priority level (higher = processed sooner) |
Scheduled jobs
Section titled “Scheduled jobs”Sometimes you need a job to run on a recurring schedule — daily cleanup of expired mutes, weekly report generation, hourly stats aggregation. The @scheduled() decorator handles this:
import {scheduled, Worker} from '@warden/jobs';
@scheduled({name: 'cleanup-expired-mutes', schedule: '0 0 * * *'})export default class CleanupExpiredMutesWorker implements Worker { constructor(private muteService: MuteService, private logger: Logger) {}
public async process(payload: Record<string, never>): Promise<void> { const removed = await this.muteService.removeExpired(); this.logger.info(`Cleaned up ${removed} expired mutes`); }}The schedule field uses standard cron syntax. This job runs at midnight every day — and crucially, it survives bot restarts. If the bot is down when a scheduled job should have fired, it will catch up when it comes back online.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique job name |
schedule | string | Yes | Cron expression (e.g. '0 0 * * *' for daily at midnight) |
payload | object | No | Static payload passed to process() on each run |
You may also pass a static payload to scheduled jobs:
@scheduled({ name: 'weekly-mod-digest', schedule: '0 9 * * 1', // every Monday at 9am payload: {type: 'weekly', includeCharts: true},})export default class WeeklyModDigestWorker implements Worker { public async process(payload: {type: string; includeCharts: boolean}): Promise<void> { // generate and send the weekly digest }}The Scheduler injectable
Section titled “The Scheduler injectable”For one-off scheduled tasks — things that aren’t recurring but need to happen at a specific time — inject the Scheduler service:
import {Scheduler} from '@warden/jobs';
@command({parent: 'mod', name: 'mute', description: 'Mute a user'})export default class ModMuteCommand extends Subcommand { constructor(private scheduler: Scheduler, private muteService: MuteService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!; const duration = interaction.options.getInteger('duration')!;
await this.muteService.mute(interaction.guildId!, target.id);
// Schedule the unmute for later await this.scheduler.in(duration * 60 * 1000, 'unmute-user', { guildId: interaction.guildId!, userId: target.id, });
await interaction.followUp({embeds: [ Embed.success(`${target.username} has been muted for ${duration} minutes.`) ]}); }}The Scheduler provides two methods:
// Schedule for a specific dateawait this.scheduler.at(new Date('2025-01-01T00:00:00'), 'new-year-announcement', { message: 'Happy New Year!',});
// Schedule after a delay (in milliseconds)await this.scheduler.in(30 * 60 * 1000, 'unmute-user', { guildId: '123', userId: '456',});Both methods create a one-shot job that fires once and is then removed. The job is persisted in the queue backend, so it survives restarts.
Relationship to @job()
Section titled “Relationship to @job()”You may have noticed that @warden/core already has a @job() decorator. So what’s the difference?
The core @job() is a simple, in-memory job runner. It’s great for fire-and-forget tasks where you don’t need persistence — logging, sending a quick webhook, updating a cache. But if the bot restarts, any pending in-memory jobs are lost.
@warden/jobs is the upgrade path for when you need:
- Persistence — jobs survive restarts
- Retries — automatic retry with configurable backoff
- Scheduling — cron-based recurring jobs
- Priority — process important jobs first
- Monitoring — inspect queue depth, job status, failure rates
- Clustering — multiple bot instances sharing the same queue
Think of @job() as setTimeout and @warden/jobs as a proper task queue. Start with @job(), and when you outgrow it, the migration is straightforward — your worker classes look almost identical.
Practical examples
Section titled “Practical examples”Queuing a report after /mod stats
Section titled “Queuing a report after /mod stats”When a moderator runs /mod stats, generate the report in the background and DM it when ready:
@worker({name: 'generate-mod-report'})export default class GenerateModReportWorker implements Worker { constructor( private caseService: CaseService, private bot: BotClient, ) {}
public async process(payload: {guildId: string; requestedBy: string}): Promise<void> { const cases = await this.caseService.getAllCases(payload.guildId); const report = await this.caseService.generateReport(cases); const attachment = this.caseService.formatAsCSV(report);
const user = await this.bot.users.fetch(payload.requestedBy); await user.send({ content: 'Your moderation report is ready.', files: [attachment], }); }}Daily cleanup of expired mutes
Section titled “Daily cleanup of expired mutes”Run every day at midnight, remove expired mutes, and log the results:
@scheduled({name: 'cleanup-expired-mutes', schedule: '0 0 * * *'})export default class CleanupExpiredMutesWorker implements Worker { constructor( private muteService: MuteService, private modLog: ModLogService, private logger: Logger, ) {}
public async process(): Promise<void> { const expired = await this.muteService.findExpired();
for (const mute of expired) { await this.muteService.unmute(mute.guildId, mute.userId); }
if (expired.length > 0) { await this.modLog.send( Embed.info(`Scheduled cleanup: removed ${expired.length} expired mute(s).`) .setTimestamp() ); }
this.logger.info(`Mute cleanup completed`, {removed: expired.length}); }}Delayed unmute with retry
Section titled “Delayed unmute with retry”When muting a user, schedule the unmute as a persistent job with retries in case the Discord API is unavailable:
@worker({name: 'unmute-user'})export default class UnmuteUserWorker implements Worker { constructor(private muteService: MuteService) {}
public async process(payload: {guildId: string; userId: string}): Promise<void> { await this.muteService.unmute(payload.guildId, payload.userId); }}// In your mute command:await this.scheduler.in(duration * 60 * 1000, 'unmute-user', { guildId: interaction.guildId!, userId: target.id,});The unmute happens at the right time, even if the bot restarts in between. And with retry support, a temporary Discord outage won’t leave someone muted forever.