Skip to content

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.Job and implementing perform()
  • How to enqueue jobs immediately, after a delay, or at a specific time
  • How to drain queues with the wheels jobs work worker, or processQueue() 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

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.

app/jobs/SendWelcomeEmailJob.cfc
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:

  1. Extend wheels.Job. The base class in vendor/wheels/Job.cfc provides enqueue, enqueueIn, enqueueAt, processQueue, and the retry machinery.
  2. Override perform(struct data). This is where the work happens. Read inputs from arguments.data. Return nothing — the framework inspects success by whether an exception was thrown.
  3. Call super.config() first, then set this.queue, this.maxRetries, this.priority, this.baseDelay, this.maxDelay as 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.

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.

app/controllers/Users.cfc (fragment)
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.

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):

your shell
wheels jobs work # process all queues; loops until Ctrl-C
wheels jobs work --queue=mailers # only drain the mailers queue
wheels 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 CI
wheels jobs work --quiet # suppress per-job output; failures still print

In 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:

your shell
wheels jobs status # per-queue pending / processing / completed / failed table
wheels jobs status --queue=mailers # one queue only
wheels jobs status --format=json # machine-readable, for CI smoke tests or metric scrapes

You 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:

app/controllers/admin/Jobs.cfc (fragment)
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 default limit).
  • processQueue(queue="mailers") — only drain the mailers queue.
  • 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:

queue health
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);
}
}

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.

app/jobs/ChargeCardJob.cfc
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():

retry failed jobs
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.

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:

drain in priority order
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.

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:

app/controllers/admin/Jobs.cfc (fragment)
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.

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():

programmatic stats
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.

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.

tests/specs/jobs/SendWelcomeEmailJobSpec.cfc
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.

Completed and failed rows accumulate forever unless you prune them. purgeCompleted() deletes completed rows older than a cutoff:

prune the table
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.

  • Enqueue from afterCreate callbacks to keep the controller response fast. The model saves, the callback drops a row into wheels_jobs, the response ships.
  • Use enqueueIn for rate limits. If a third-party API caps you at one call per second, stagger calls with enqueueIn(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} beats data={user: user}. The fresh findByKey() in perform() sees the current state, not a snapshot from when you enqueued.