Skip to main content

Services

Any class that extends ApplicationBackgroundedService provides the background and backgroundWith methods that are used to background methods on the class. For instance, let's say you have a service that syncs your user data to a service like intercom, twilio, salesforce, helpdesk, etc...

class IntercomSync {
public static async syncUser(user: User) {
// ...sync user to intercom
}
}

You can easily make this a backgroundable service. Simply extend ApplicationBackgroundedService, and now you can background:

class IntercomSync extends ApplicationBackgroundedService {
public static async syncUser(user: User) {
await this.background('_syncUser', user.id)
}

public static async _syncUser(id: IdType) {
const user = await User.find(id)
if (!user) return
// ...sync user to intercom
}
}

Now, from anywhere in your app you can safely call syncUser without tying up resources on the api request!

Pass IDs or scalar values to background jobs, not model objects. Job arguments are serialized through Redis; model instances can go stale, bloat Redis memory with a full JSON payload, and lose type information (a Dream date/time object becomes a plain string, an enum becomes an untyped value). Inside the backgrounded implementation, use find (not findOrFail) and return early if the record no longer exists — a record can be deleted between when the job is queued and when a worker picks it up, and findOrFail would throw and trigger the full retry budget (~20 attempts over ~6 days) against a record that will never exist again.

A composed scalar argument can still be model-sourced data in spirit if it bakes in a secret. A string that embeds a live credential — a bearer token, a signed URL, a password-reset link — passes the letter of "simple scalar value" while still writing that secret into BullMQ/Redis job retention and any queue dashboard or error-monitoring tool that logs job arguments on failure. Defer minting the sensitive part to the implementation method instead: pass only the id(s) needed to look the record up, and construct the token or link from inside the backgrounded method, where it's never serialized as a job argument.

// Wrong — the signed URL (with its embedded token) is stored as a job argument
class BookingMailerService extends ApplicationBackgroundedService {
public static async sendConfirmation(booking: Booking) {
const confirmationUrl = await booking.mintConfirmationUrl()
await this.background('_sendConfirmation', booking.guestId, confirmationUrl)
}
}

// Right — only the booking id crosses the queue; the token is minted inside the job
class BookingMailerService extends ApplicationBackgroundedService {
public static async sendConfirmation(booking: Booking) {
await this.background('_sendConfirmation', booking.id)
}

public static async _sendConfirmation(bookingId: string) {
const booking = await Booking.find(bookingId)
if (!booking) return
const confirmationUrl = await booking.mintConfirmationUrl()
// ...email booking.guest the confirmationUrl
}
}

If you enqueue from model hooks, use an after-commit hook such as @deco.AfterCreateCommit, @deco.AfterUpdateCommit, or @deco.AfterSaveCommit. This applies to any enqueue path: calling a backgrounded service, calling this.background(...) on a backgrounded model, or calling a helper that queues the job. Do not enqueue from inside an open transaction; the worker can race the commit and either look up a record before it exists or read stale persisted data after an update. A service that takes a txn parameter and also calls background(...) in its body is almost always this bug — enqueue after the transaction resolves instead, or let a commit hook do it.

Work that must not be lost after a commit​

A commit hook runs after its write has committed, so an enqueue that fails inside it leaves the write in place with nothing queued (see after commit hooks), and a deploy or crash can land between a commit and the job meant to follow it. When losing that follow-on work costs something real, such as the charge for a confirmed booking, let two timestamps hold it: the confirming write sets chargeInitiatedAt, the job sets chargeCompletedAt after the gateway call, and a booking with the first and not the second is owed, whatever became of its job. The commit hook is the fast path; a scheduled sweep that re-enqueues every owed booking is the guarantee.

The confirming write claims the booking with a locked update, and the model's commit hook hands it to the service:

await Booking.where({ id: booking.id, status: 'pending' }).update(
{ status: 'confirmed', chargeInitiatedAt: DateTime.now() },
{ lock: true },
)
// app/models/Booking.ts
@deco.AfterUpdateCommit({ ifChanged: ['chargeInitiatedAt'] })
public async chargeGuest(this: Booking) {
if (this.chargeInitiatedAt) await BookingChargeService.charge(this)
}
// app/services/BookingChargeService.ts
import { range } from '@rvoh/dream/utils'
import { DateTime } from '@rvoh/dream'

// the gateway keeps an idempotency key for 24 hours; stay inside that
const keyWindowStart = () => DateTime.now().minus({ hours: 20 })

export default class BookingChargeService extends ApplicationBackgroundedService {
public static async charge(booking: Booking) {
await this.background('_charge', booking.id)
}

public static async _charge(bookingId: string) {
const booking = await Booking.findBy({
id: bookingId,
chargeInitiatedAt: range(keyWindowStart()),
chargeCompletedAt: null,
})
if (!booking) return
await PaymentGateway.charge(booking, { idempotencyKey: `booking-charge-${booking.id}` })
await booking.update({ chargeCompletedAt: DateTime.now() })
}

// called every minute by a scheduled service
public static async chargeOwed() {
await this.background('_chargeOwed')
}

public static async _chargeOwed() {
await Booking.where({
// leave the last two minutes to the commit hook's fast path
chargeInitiatedAt: range(keyWindowStart(), DateTime.now().minus({ minutes: 2 })),
chargeCompletedAt: null,
}).findEach(async booking => {
await this.charge(booking)
})
}
}

The lookup in _charge only skips work that is no longer owed. Two overlapping runs both pass it, and a charge can succeed before chargeCompletedAt is saved, so the idempotency key is what makes a repeat harmless. It does so only while the gateway keeps the key, commonly 24 hours; past that, a repeat is a new charge, which is why the job and the sweep both stop at 20 hours. Report anything owed beyond that window for a person to reconcile against the gateway, from its own scheduled task with a cutoff an hour past the window, so it never reports a booking a job may still charge.

A write that ends the obligation, such as a cancellation, clears chargeInitiatedAt in the same write, so neither the job nor the sweep picks the booking up. When adding the two columns to a table that already holds charged bookings, backfill chargeCompletedAt beside any chargeInitiatedAt you backfill, so only new confirmations read as owed.

Every dispatch appends the Job, so no parameter may be optional​

Psychic appends the BullMQ Job as a final argument to every dispatched method; this.background(...) and this.backgroundWith(...) pass only the arguments before it. A method that wants the Job declares it as a final parameter (see Logging in background jobs).

Because of that appended argument, no parameter of a backgrounded method may carry a default or a ?. Give the last parameter either and the appended Job lands in that slot: the default never applies, and the method runs with a Job where it expected its own value — in specs too, which dispatch through the same path.

// WRONG — the appended Job lands in `attempt`, and the default never applies
public static async _geocodePlace(placeId: string, attempt = 1) {}

// RIGHT — the caller seeds the value, and the parameter is required
public static async _geocodePlace(placeId: string, attempt: number) {}

Scheduled methods are dispatched the same way but must not declare the Job: schedule() requires every parameter the method declares, so a final job: Job becomes an argument the call site has to pass.

backgroundJobConfig is class-level​

backgroundJobConfig sets the class's defaults, and only priority can be overridden per call: backgroundWith({ delay, priority }, method, ...args) replaces the getter's priority for that one job, whether or not the service carries a workstream.

opts carries no routing. The getter's workstream governs every backgrounded method on the service, immediate and delayed alike; you cannot route one method to its own workstream from a single class. To isolate a subset of a service's jobs, extract those methods into a separate backgrounded service with its own backgroundJobConfig — splitting the class is the only mechanism.

Never catch-and-continue inside a backgrounded method​

The bar for adding a try/catch inside a class extending ApplicationBackgroundedService or ApplicationBackgroundedModel is higher than elsewhere in the app. BullMQ relies on thrown exceptions to detect failure, so a caught-and-not-rethrown error marks the job successful: no failed-job marker in BullMQ, no automatic retry, and the work is silently lost until someone reads worker logs.

// WRONG — log-and-continue inside a backgrounded service
public static async _importAll(placeIds: string[]) {
for (const placeId of placeIds) {
try {
await this.importOne(placeId)
} catch (error) {
console.error('Failed:', error) // BullMQ never sees this; job marks success
}
}
}

// RIGHT — let the first failure propagate, and let BullMQ retry the whole job
public static async _importAll(placeIds: string[]) {
for (const placeId of placeIds) {
await this.importOne(placeId)
}
}

// ALSO RIGHT — fan out to per-record jobs, each retried independently by BullMQ
public static async importAll(places: Place[]) {
for (const place of places) {
await this.background('_importOne', place.id)
}
}

If your motivation is "I want to be resilient to one bad input out of many," the fix is separate background jobs per input, not a try/catch loop that fakes per-iteration success. See Automatic retry for the one narrow, justified exception — a service that owns its own retry budget for a specific expected error.

Fanning out across very large record sets​

Enqueuing millions of jobs up front runs into Redis memory pressure, interrupted-loop duplication, and unusable dashboards. For hundreds of thousands to millions of records, use the two-level fan-out pattern instead — see Fanning out large record sets.