Using the Job Queue
The jobs module turns backendjs into a task queue system. You submit work as plain objects, and worker processes pick it up and run it. Jobs can execute in-process (local), in a forked worker on the same host, or in a worker on a remote host β all through the same API.
Every job function looks the same regardless of backend:
function myJob(options, callback) {
// do work with `options`
callback(); // success
callback(new Error("oops")); // failure β queued for retry
}
Anatomy of a job
A job is an object with a job property. The property maps a "module.method" string to its arguments:
{ "job": { "mymod.sendEmail": { to: "a@b.com", subject: "Hi" } } }
A job can contain multiple tasks, and they'll run one by one:
{
"job": {
"mymod.sendEmail": { to: "a@b.com" },
"mymod.logResult": { result: "done" }
}
}
For convenience, a string shorthand is accepted and normalized automatically:
// All three are equivalent:
"mymod.sendEmail"
{ job: "mymod.sendEmail" }
{ job: { "mymod.sendEmail": {} } }
The module.method name must match ^[a-z0-9_.]+\.[a-z0-9_]+$ β dot-separated, alphanumeric + dots/underscores.
A complete module with a job
You need two pieces: a route to submit the job and a method to process it.
modules/email.js
const { app, api, modules, jobs, db, logger } = require("backendjs");
module.exports = {
name: "email",
// ββ API route: submit and return immediately ββββββββββββββββββββββββββ
configureMiddleware(options, callback) {
api.app.post("/emails", (context) => {
jobs.submit(
{ job: { "email.send": { to: context.body.to, subject: context.body.subject } } },
{ queueName: "emails" }, // which worker queue to use
(err) => context.reply(err) // reply once the job is enqueued
);
});
callback();
},
// ββ Worker method: the actual job logic βββββββββββββββββββββββββββββββ
sendEmail(options, callback) {
logger.info("emails:", "sending to", options.to, options.subject);
// ... send email, write to db, etc.
callback();
}
};
app.start({ server: true });
bkjs.conf
queue-emails = sqs://my-emails
queue-emails-visibility-timeout = 60000
Start the server (spawns 2 workers by default):
node index.js -jobs-workers 2 -jobs-worker-queue emails
Now POST /emails returns immediately. A worker on the same or a different host picks up the message.
Calling from another module
Any module can submit a job without importing jobs if it calls through the module system:
modules.myjob.runJob({ "queueName": "emails" }, (err) => { ... });
Or directly:
const { jobs } = require("backendjs");
jobs.submit({ job: { "email.send": { to: "x@y.com" } } }, { queueName: "emails" });
Queues
Two queues exist by default:
| Queue | Behavior |
|---|---|
local |
Runs in the current process β no worker fork. Fast for short tasks. |
worker |
Runs in a forked worker process β survives restarts of the web server. |
You can create as many named queues as you like by defining them in bkjs.conf or -queue-* params, then workers subscribe to them via -jobs-worker-queue.
Each queue maps to a queue driver β SQS, Redis, RabbitMQ, NATS, or an in-memory local queue. The driver is selected by the URI scheme in -queue-<name>:
queue-letters = sqs://letters
queue-emails = redis://localhost:6379/0
queue-events = nats://localhost:4222/events
A queue can be a list of driver names β the job is submitted to the next one in round-robin, useful for load balancing:
queue-events = nats://host1 nats://host2
To stop subscribing to a queue on workers, prefix the name with - or !:
node index.js -jobs-worker-queue "-worker emails" # unsubscribe worker, subscribe emails
Default queue override
Set -jobs-global-queue myqueue to force all submissions to one queue (ignores the per-call queueName). The local and worker queues are always ignored by the global override.
Submitting jobs
API
// Callback
jobs.submit(jobspec, options, callback);
// Async (prefixed with 'a')
const { err, data } = await jobs.asubmit(jobspec, options);
Running directly (skip the queue)
// Callback
jobs.run(jobspec, { queueName: "local" }, callback);
// Async
const { err, data } = await jobs.arun(jobspec, { queueName: "local" });
Submit to a specific queue
jobs.submit(jobspec, { queueName: "orders" });
Multiple jobs at once
Pass an array of job specs β it submits each one and waits for all to complete:
jobs.submit([
{ job: { "email.send": { to: "a@b.com" } } },
{ job: { "email.send": { to: "c@d.com" } } },
], { queueName: "emails" }, (err) => {
// all enqueued
});
Job options
All options are part of the options object passed to submit or on the jobspec itself. They can also be set per-queue in bkjs.conf, in which case they apply to every job submitted to that queue.
Dedup and uniqueness
| Option | Description |
|---|---|
uniqueTtl |
Number of milliseconds. Creates a cache lock on the job. If a second job with the same key arrives while the lock is held, it waits (or is dropped). |
uniqueKey |
Custom key for the lock. Useful to serialize jobs by a business key: uniqueKey: "ORDER:" + orderId |
uniqueDrop |
true β instead of queuing a duplicate job, drop it silently. |
uniqueKeep |
true β keep the lock after the job finishes, preventing all future duplicates. |
uniqueOnce |
true β stop extending the visibility timeout while the job is running. |
dedupTtl |
Number of milliseconds to remember recently-seen messages. Prevent the same job being processed twice. |
// Serialize orders for the same account β 5 min gap between two executions
jobs.submit(job, {
queueName: "orders",
uniqueKey: `ORDER:${accountId}`,
uniqueTtl: 300000,
});
// Drop duplicate submissions while a job for this account is running
jobs.submit(job, {
queueName: "orders",
uniqueKey: `ORDER:${accountId}`,
uniqueDrop: true,
uniqueTtl: 60000,
});
Visibility and retry
| Option | Description |
|---|---|
visibilityTimeout |
Ms the job is invisible to other workers while processing. Prevents double execution. |
noVisibilityTimeout |
true β disable auto-extending the timeout during the job. |
noRetryVisibilityTimeout |
true β ignore 600 errors, delete the job after processing. |
retryVisibilityTimeout |
Map of error status β timeout in ms. Controls retry delays per error code. |
// 5-minute visibility window, 600+ errors cause a 30-second retry
jobs.submit(job, {
queueName: "orders",
visibilityTimeout: 300000,
retryVisibilityTimeout: { 600: 30000, 500: 10000 },
});
Timing
| Option | Description |
|---|---|
delay |
ms to wait before the job becomes visible (SQS only). |
startTime |
Unix ms β job will not start before this time. |
endTime |
Unix ms β job is dropped if not started by this time. |
noWait |
true/1 β delete the job from the queue immediately after processing. A number gives a delay before deletion. |
// Run an hour from now; delete the message immediately after it finishes
jobs.submit(job, {
queueName: "reports",
startTime: Date.now() + 3600000,
noWait: 1,
});
Error handling
| Option | Description |
|---|---|
stopOnError |
true β stop at the first task error. Without it, all tasks run and each error is logged. |
logger |
Log level string used when the job finishes. Default: "debug". |
// Fail fast on first error
jobs.submit(job, { queueName: "orders", stopOnError: true });
// Log job completion at "warn" level
jobs.submit(job, { queueName: "orders", logger: "warn" });
Task ignore
A regex in -jobs-task-ignore silently skips any task whose name matches. Useful in development to block a slow task:
jobs-task-ignore = mymod.expensiveTask
Cancellation
To interrupt running jobs, broadcast a cancel key via IPC. Each job function should poll and respect it:
// From server or client
const { jobs, ipc } = require("backendjs");
jobs.cancel("order:123");
Inside the job method:
processOrder(options, callback) {
// Poll for cancellation
if (jobs.isCancelled("order:" + options.id)) {
return callback(lib.newError("Cancelled", 600, "JobCancelled"));
}
// ...
}
Cron / scheduled jobs
Define recurring jobs with cron expressions. Cron expression format (note the optional leading second field):
ββ second (0-59, optional)
β ββ minute (0-59)
β β ββ hour (0-23)
β β β ββ day of month (1-31)
β β β β ββ month (1-12)
β β β β β ββ day of week (0-6, 0 and 7 are Sunday)
β β β β β β
* * * * * *
From a JSON file
Create a file crontab.json:
[
{ "cron": "0 0 * * *", "job": "email.dailyDigest", "queueName": "reports" },
{
"cron": "0 3 * * 1,3,5",
"job": { "reports.weekly": { type: "finance" } },
"queueName": "reports",
"uniqueTtl": 600000
},
{ "cron": "* * * * *", "job": "healthbeat.ping", "disabled": true }
]
Start with:
node index.js -jobs-workers 1 -jobs-cron-file crontab.json
The file is watched for changes β update it while the server runs and jobs are re-scheduled.
From -jobs-cron config or scheduleCronjobs
// In bkjs.conf:
// jobs-cron = [{"cron":"0 12 * * *","job":"reports.summary"}]
const { jobs } = require("backendjs");
// Programmatic:
jobs.scheduleCronjob({
cron: "0 0 * * * *",
job: "reports.nightlySummary",
queueName: "reports",
});
// Schedule a batch, replacing previous jobs of the same type:
jobs.scheduleCronjobs("config", [
{ cron: "*/5 * * * * *", job: "healthbeat.ping" },
{ cron: "0 0 * * *", job: "reports.sum", queueName: "reports" },
]);
From the config DB
The -jobs-cron param can also be set from the database config. When the config DB updates, scheduleCronjobs("config", newJobs) is called automatically β no restart needed.
Module hooks (lifecycle)
Job lifecycle emits hooks you can implement on custom modules:
module.exports = {
name: "myhook",
// Called before each job runs. Return an error to skip the job.
configureJob(options, callback) {
// options = { queue, message }
if (options.message?.job && !isAllowed(options.message.job)) {
return callback(new Error("Not allowed"));
}
callback();
},
// Called after a job finishes (err or success)
finishJob(options, callback) {
// options = { queue, message, error, elapsed }
console.log("Job finished in", options.elapsed, "ms");
callback();
},
}
configureJob runs before job execution and can abort it by returning an error. finishJob is parallel and direct β it will not slow down queue processing.
You can also listen for job events via IPC messages without implementing a module method:
const { ipc } = require("backendjs");
ipc.on("jobs:started", ({ job, queueName }) => console.log("started:", job, queueName));
ipc.on("jobs:stopped", ({ job, err, queueName }) => console.log("stopped:", err ? "error" : "ok", queueName));
ipc.on("jobs:task:started", ({ name, job }) => console.log("task started:", name));
ipc.on("jobs:task:stopped", ({ name, job, err }) => console.log("task stopped:", name, err));
ipc.on("jobs:nolock", ({ job, err, queueName }) => logger.warn("job blocked by lock:", job, err));
ipc.on("jobs:dropped", ({ job, queueName }) => logger.info("job dropped:", job, queueName));
Worker configuration
| Config | Default | Description |
|---|---|---|
jobs-workers |
0 (auto = cpuCount * workerCpuFactor) |
Number of processes to fork. -1 disables workers. 0 = auto. |
jobs-worker-cpu-factor |
2 |
Multiplier for auto worker count (cpuCount Γ 2 by default). |
jobs-worker-queue |
worker |
Queue name(s) workers subscribe to. List = subscribe to all. |
jobs-worker-delay |
50 |
ms to delay worker subscription start β prevents race on simultaneous starts. |
jobs-worker-settings |
β | Passed to cluster.setupMaster(). |
jobs-worker-env |
β | Environment passed to forked workers. |
jobs-max-runtime |
900000 |
Max ms per job. Exceeding kills the worker. |
jobs-max-lifetime |
12 h |
Max ms a worker lives. Exits after current job, new worker spawned. |
jobs-worker-options-<queue> |
β | Per-queue options passed to queue.subscribeQueue. |
jobs-shutdown-timeout |
50 |
ms to wait during graceful worker shutdown. |
Graceful worker lifecycle
When a worker is idle for longer than max-lifetime, it stops accepting new jobs, finishes in-flight work, and exits. The server process then forks a fresh one. The shutdownWorker hook fires before exit β use it to close connections or flush buffers:
const { modules } = require("backendjs");
module.exports = {
name: "cleanup",
shutdownWorker(options, callback) {
// options.shutdownReason = "maxLifetime" | "maxRuntime" | "restart"
// Close pending DB connections, flush buffers, etc.
callback();
}
};
Running locally vs. remote
| Mode | Config / param | Where the job runs |
|---|---|---|
| Local submit | queueName: "local" or default |
Current process, no fork |
| Worker | queueName: "worker" or a custom queue with workers |
Forked child process on the same host |
| Remote | queueName pointing to SQS/Redis/etc. |
Any host connected to the same queue |
The same API (jobs.submit) is used for all three β only the queue driver changes.
Job format validation
jobs.isJob(jobspec) validates and normalizes any job input. It accepts:
- string:
"mymod.method"β must match^[a-z0-9_.]+\.[a-z0-9_]+$ - object:
{ job: "mymod.method" }or{ job: { "mymod.method": { β¦ } } }
It returns an Error with status 400 for anything else. All submit/run calls pass through this validation.
Complete example: background order processing
// modules/order.js
const { api, modules, jobs, db, logger, lib } = require("backendjs");
module.exports = {
name: "order",
tables: {
bk_order: {
id: { type: "uuid", primary: 1, read_only: true },
product: { type: "text" },
status: { type: "text", value: "pending" },
ctime: { type: "now", read_only: true }
}
},
// ββ API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
configureMiddleware(options, callback) {
// Public: create an order and kick off processing
api.app.post("/orders", async (context) => {
const order = await modules.order.createOrder(context.body);
context.json(order);
});
callback();
},
// ββ Job entry point βββββββββββββββββββββββββββββββββββββββββββββββββββ
configureServer(options, callback) {
// Start cron: process pending orders every minute
jobs.scheduleCronjob({
cron: "0 * * * * *", // every minute
job: "order.processBatch",
queueName: "orders",
uniqueTtl: 60000, // don't overlap runs
});
callback();
},
// ββ API method to create an order βββββββββββββββββββββββββββββββββββββ
async createOrder(body) {
const { err, data: order } = await db.aadd("bk_order", {
product: body.product
}, { returning: "*", first: 1 });
if (err) throw err;
// Async-variant helper
const { err: submitErr } = await jobs.asubmit(
{ job: { "order.process": { orderId: order.id } } },
{
queueName: "orders",
uniqueKey: "ORDER:" + order.id,
uniqueTtl: 120000,
visibilityTimeout: 60000,
}
);
if (submitErr) throw submitErr;
return order;
},
// ββ Job: called in a worker βββββββββββββββββββββββββββββββββββββββββββ
process(options, callback) {
logger.info("orders:", "processing", options.orderId);
db.get("bk_order", { id: options.orderId }, (err, order) => {
if (err) return callback(err);
if (!order) return callback(lib.newError("Order not found", 404));
// Do the workβ¦
db.update("bk_order", {
id: order.id,
status: "completed"
}, (err2) => {
if (err2) return callback(err2);
logger.info("orders:", "done", order.id);
callback();
});
});
}
};
bkjs.conf
queue-orders = sqs://orders
queue-orders-visibility-timeout = 60000
queue-orders-options-retry-visibility-timeout = {"500":30000,"600":120000}
jobs-workers = 2
node index.js -jobs-worker-queue orders
Quick reference
Submit: jobs.submit(jobspec, options, cb) / await jobs.asubmit(...)
Run locally: jobs.run(jobspec, { queueName: "local" }, cb) / await jobs.arun(...)
Validate: jobs.isJob(jobspec) β normalized obj or Error
Schedule: jobs.scheduleCronjob({ cron, job, queueName })
Batch schedule: jobs.scheduleCronjobs("config", [ β¦jobs ])
Cancel: jobs.cancel("key") β broadcasts to all workers
Check cancel: jobs.isCancelled("key")
Stats: jobs.metrics.running, jobs.metrics.err_count (via configureCollectStats)