Digging Deeper
Background Jobs
This page shows you how to move slow work out of the request cycle. You’ll define a job class, enqueue it from a controller, drain the queue with the wheels jobs work worker (or processQueue() on a schedule), configure retries with exponential backoff, and route urgent work through a high-priority queue.
You’ll learn:
- How to define a job by extending
wheels.Joband implementingperform() - How to enqueue jobs immediately, after a delay, or at a specific time
- How to drain queues with the
wheels jobs workworker, orprocessQueue()from a scheduled task - How retries and backoff work, and how to tune them
- How priority queues let critical work jump the line
- How to monitor, retry, and purge jobs in production
- How to test
perform()in isolation
Define a job
Section titled “Define a job”A job is a CFC in app/jobs/ that extends wheels.Job. Override config() to set queue and retry options, and override perform() with the work itself. The base class handles enqueueing, locking, retries, and persistence.
component extends="wheels.Job" { function config() { super.config(); this.queue = "mailers"; this.maxRetries = 5; }
public void function perform(required struct data) { user = model("User").findByKey(arguments.data.userId); sendEmail( to=user.email, subject="Welcome!", from="app@example.com" ); }}Three rules:
- Extend
wheels.Job. The base class invendor/wheels/Job.cfcprovidesenqueue,enqueueIn,enqueueAt,processQueue, and the retry machinery. - Override
perform(struct data). This is where the work happens. Read inputs fromarguments.data. Return nothing — the framework inspects success by whether an exception was thrown. - Call
super.config()first, then setthis.queue,this.maxRetries,this.priority,this.baseDelay,this.maxDelayas needed.
Keep data small. Pass IDs, not whole model instances — the struct is serialized as JSON into a TEXT column, and a stale serialized snapshot is worse than a fresh findByKey() lookup at process time.
Enqueue a job
Section titled “Enqueue a job”Instantiate the job and call one of three enqueue methods. The typical call site is a controller action (after create() succeeds) or a model afterCreate callback.
component extends="Controller" { function create() { user = model("User").new(params.user); if (user.save()) { // Immediate — runs on the next processQueue() poll job = new app.jobs.SendWelcomeEmailJob(); job.enqueue(data={userId: user.id});
// Delayed — a gentle follow-up five minutes later followup = new app.jobs.SendFollowupEmailJob(); followup.enqueueIn(seconds=300, data={userId: user.id});
// Scheduled — onboarding drip at a specific time drip = new app.jobs.SendDripEmailJob(); drip.enqueueAt(runAt=DateAdd("d", 1, Now()), data={userId: user.id});
redirectTo(route="user", key=user.id); } else { renderView(action="new"); } }}enqueue(), enqueueIn(), and enqueueAt() all return a struct with the persisted row’s id. The controller keeps responding fast — the actual email send happens when the next processQueue() poll picks the row up.
Process the queue
Section titled “Process the queue”The simplest way to drain the queue is the bundled worker — a long-lived CLI process that polls the wheels_jobs table, claims one pending job per cycle, and runs it. It talks to your running app server, so start that first (wheels start):
wheels jobs work # process all queues; loops until Ctrl-Cwheels jobs work --queue=mailers # only drain the mailers queuewheels jobs work --queue=mailers --interval=3 # poll every 3 seconds when idle (default 5)wheels jobs work --max-jobs=100 # stop after 100 jobs — one-shot batches from cron or CIwheels jobs work --quiet # suppress per-job output; failures still printIn production, run the worker under a process supervisor (systemd, Docker’s restart policy, Kamal accessory containers): when it loses the server it exits non-zero and the supervisor restarts it. Run several workers for parallelism — the framework uses optimistic row locking, so two workers never run the same job twice. --queue accepts a comma-delimited list (--queue=critical,default); jobs across the listed queues run in priority DESC, runAt ASC order.
Check queue health without tailing logs:
wheels jobs status # per-queue pending / processing / completed / failed tablewheels jobs status --queue=mailers # one queue onlywheels jobs status --format=json # machine-readable, for CI smoke tests or metric scrapesYou don’t need a separate worker process, though. Processing is a poll: processQueue() picks up every pending job whose runAt has arrived, runs each one, and returns counts. Call it from anything that runs on a schedule — a CFML scheduled task, a cron-invoked script, or a controller action behind an admin route that your scheduler hits:
component extends="Controller" { function drain() { worker = new wheels.Job(); result = worker.processQueue(queue="mailers", limit=10); // result = {processed: n, failed: n, skipped: n, errors: [...]} renderWith(data=result); }}processQueue()— process all queues, up to 10 jobs per call (the defaultlimit).processQueue(queue="mailers")— only drain themailersqueue.processQueue(limit=100)— bigger batch for one-shot catch-ups.
In production, schedule the call at whatever cadence matches your latency tolerance — every minute is plenty for emails. Run it from multiple schedulers for parallelism: the base class uses optimistic row locking, so two pollers won’t process the same job twice (a row claimed between the SELECT and the claim shows up in the skipped count).
Check queue health without tailing logs — queueStats() returns per-status counts:
component extends="Controller" { function status() { stats = (new wheels.Job()).queueStats(); // all queues mailerStats = (new wheels.Job()).queueStats(queue="mailers"); // one queue // {pending: n, processing: n, completed: n, failed: n, total: n} renderWith(data=stats); }}Retries and backoff
Section titled “Retries and backoff”When perform() throws, the framework reschedules the job with exponential backoff until maxRetries is exhausted, at which point the row moves to status='failed' and stops being picked up.
component extends="wheels.Job" { function config() { super.config(); this.queue = "payments"; this.maxRetries = 5; this.baseDelay = 2; // seconds this.maxDelay = 3600; // cap at one hour }
public void function perform(required struct data) { order = model("Order").findByKey(arguments.data.orderId); order.chargeCard(); // may throw on transient network errors }}The backoff formula is Min(this.baseDelay * 2^attempt, this.maxDelay), where attempt is the number of the attempt that just failed (1 for the first run). Note that maxRetries counts total attempts, not retries: with the example’s settings (maxRetries=5, baseDelay=2, maxDelay=3600) the job runs at most five times — the first run plus four retries at roughly 4s, 8s, 16s, and 32s — and the fifth failure moves it to failed. Transient failures resolve quickly, and maxDelay caps the gap so a job configured with many attempts stops hammering the external service. (The framework default is maxRetries=3: the first run plus retries at 4s and 8s.)
Because retries are automatic, your perform() must be idempotent. If the first attempt charged the card but failed to record the success, the second attempt must notice and skip. Store an idempotency key on the model and check it at the top of perform().
To kick failed jobs back into the queue — after you’ve fixed the bug, say — call retryFailed():
component extends="Controller" { function retryAll() { worker = new wheels.Job(); worker.retryFailed(); // retry every failed job worker.retryFailed(queue="mailers"); // only the mailers queue redirectTo(back=true); }}Retry resets attempts to zero, clears the stored error, and flips status back to pending — the next processQueue() poll picks the rows up.
Priority queues
Section titled “Priority queues”Two levers control which work runs first. Within a single processQueue() call, jobs run in priority DESC, runAt ASC order — set this.priority in config() (higher first) or pass priority= at enqueue time and urgent rows jump the line. Across queues, drain them in the order you care about:
component extends="Controller" { function drain() { worker = new wheels.Job(); worker.processQueue(queue="critical", limit=50); worker.processQueue(queue="default", limit=20); worker.processQueue(queue="low", limit=5); renderText("ok"); }}Every pending job in critical (up to the limit) goes first, then default, then low. Queues are just string labels on the wheels_jobs.queue column — create as many as you need. A typical split:
critical— payment retries, password resets, anything user-visible.default— welcome emails, webhooks, routine follow-ups.low— nightly analytics rollups, cleanup, anything that can wait.
Per-job priority is the finer instrument; queue-based separation is simpler to reason about. Use queues for cadence, priority for jumping the line within one.
Monitoring in production
Section titled “Monitoring in production”For headless metrics, scrape wheels jobs status --format=json from a cron job or a Prometheus textfile exporter. (An interactive wheels jobs monitor dashboard is a tracked follow-up in #3090.) The same numbers are available in-app via queueStats() — expose it from a small admin endpoint and point whatever you already use — Prometheus, Datadog, a custom scrape — at the JSON:
component extends="Controller" { function metrics() { renderWith(data=(new wheels.Job()).queueStats()); }}Alert on two signals: failed growing, and pending not shrinking between polls. The first means a job class is throwing past its retries; the second means nothing is draining the queue.
The jobs table
Section titled “The jobs table”Jobs persist to a wheels_jobs table keyed by UUID. The base class auto-creates it the first time enqueue() or processQueue() runs — no migration needed. Columns: id, jobClass, queue, data (JSON), priority, status (pending/processing/completed/failed), attempts, maxRetries, lastError, runAt, completedAt, failedAt, createdAt, updatedAt. See vendor/wheels/Job.cfc ($ensureJobTable) for the exact schema — it adapts column types per database (MySQL, PostgreSQL, SQL Server, H2, SQLite, Oracle).
You never need to touch the table directly. Query it through queueStats():
component extends="Controller" { function dashboard() { job = new wheels.Job(); stats = job.queueStats(); // all queues mailerStats = job.queueStats(queue="mailers"); }}stats is a struct with pending, processing, completed, failed, and total.
Testing jobs
Section titled “Testing jobs”perform() is a plain public method that takes a struct. Don’t exercise the queue in a test — instantiate the job, call perform() with fixture data, and assert on the side effects.
component extends="wheels.WheelsTest" { function run() { describe("SendWelcomeEmailJob", () => { it("sends a welcome email to the user", () => { testUser = model("User").create( email="new@example.com", firstName="New" );
job = new app.jobs.SendWelcomeEmailJob(); job.perform(data={userId: testUser.id});
// Assert your email spy or outbox captured the send. // The pattern depends on how you stub sendEmail() — // see the Testing guide for the email-fake approach. expect(true).toBeTrue(); }); }); }}Testing perform() in isolation keeps the spec fast and avoids the complexity of polling a live queue. The queue itself is framework code — trust it.
Purging old rows
Section titled “Purging old rows”Completed and failed rows accumulate forever unless you prune them. purgeCompleted() deletes completed rows older than a cutoff:
component extends="Controller" { function purge() { worker = new wheels.Job(); worker.purgeCompleted(); // completed jobs older than 7 days worker.purgeCompleted(days=30); // completed older than 30 days worker.purgeCompleted(days=30, queue="mailers"); // one queue only renderText("ok"); }}It only touches status='completed' rows — failed rows persist until you clear them yourself, which is deliberate: they’re your error log. Retry them with retryFailed() or delete them with your own SQL once you’ve extracted what you need from lastError.
Run the purge from a cron or scheduled task. A weekly purge of completed rows older than 30 days keeps the table small without losing recent history for debugging.
Common patterns
Section titled “Common patterns”- Enqueue from
afterCreatecallbacks to keep the controller response fast. The model saves, the callback drops a row intowheels_jobs, the response ships. - Use
enqueueInfor rate limits. If a third-party API caps you at one call per second, stagger calls withenqueueIn(seconds=1),enqueueIn(seconds=2), and so on — cheaper than a token-bucket middleware for low-volume cases. - One job per side effect. Don’t bundle email + Slack + webhook into a single
perform()— if Slack is down, you don’t want to retry the email and the webhook. Enqueue three separate jobs. - Make
perform()idempotent. Every job will, eventually, run twice. Plan for it: check-before-write, store idempotency keys, use unique database constraints. - Pass IDs, not objects.
data={userId: user.id}beatsdata={user: user}. The freshfindByKey()inperform()sees the current state, not a snapshot from when you enqueued.