Background Jobs
Background Jobs
JSails ships a provider-neutral job runtime: you define a job with a Zod schema
and a typed handler, bind the registry to a transport adapter, and drive it with
the work and schedule commands. The built-in transport is BullMQ over
Valkey/Redis, but the runtime itself never assumes a backend — a custom adapter
needs no connection URL at all.
Defining a job
defineJob(schema, handler) pairs a Zod schema with a typed handler, and
createJobRegistry({...}) validates the map. Payloads are validated on dispatch
and on the worker, so a malformed payload is rejected at both ends of the
queue.
import { z } from 'zod';
import { defineJob, createJobRegistry } from 'jsails';
const sendEmail = defineJob(
z.object({ to: z.string(), subject: z.string() }),
async (data, ctx) => { await ctx.log(`send to ${data.to}`); },
);
export const registry = createJobRegistry({ sendEmail });
The runtime
createJobsRuntime({ registry, adapter, queueName?, prefix?, concurrency? })
binds the registry to a JobsRuntimeAdapter and owns job-level policy: payload
validation on enqueue and on processing, dispatch-option allowlisting, local
schedule validation, lazy and idempotent handles, and an idempotent close.
An adapter is { name, createProducer, createWorker, upsertSchedules? }. When
upsertSchedules is absent, runtime.upsertSchedules throws an explicit
JobsRuntimeError instead of silently doing nothing. validateJobsRuntimeAdapter
checks an adapter's shape without invoking any factory.
createBullMQAdapter({ redisUrl, onError?, queueFactory?, workerFactory? }) is
the built-in adapter and the default transport. A custom adapter needs no
connection URL — createJobsRuntime never receives one.
Configuring the runtime — jsails.runtime.js
The work and schedule commands load a compiled ESM config whose default
export carries the registry, an optional adapter, and the schedule list:
// jsails.runtime.js (default config for `work` / `schedule`)
import { z } from 'zod';
import { defineJob, createJobRegistry } from 'jsails';
const sendEmail = defineJob(
z.object({ to: z.string(), subject: z.string() }),
async (data, ctx) => { await ctx.log(`send to ${data.to}`); },
);
export default {
registry: createJobRegistry({ sendEmail }),
// `adapter` is optional. Omit it to use the built-in BullMQ adapter, which
// then requires a Valkey/Redis URL (valkeyUrl config key, or VALKEY_URL in
// the environment). Supply a custom JobsRuntimeAdapter to target another
// backend with no URL at all.
valkeyUrl: process.env.VALKEY_URL ?? 'redis://127.0.0.1:6379',
schedules: [{ id: 'digest', job: 'sendEmail', cron: '0 3 * * *',
data: { to: '[email protected]', subject: 'digest' } }],
queueName: 'default',
concurrency: 1,
};
When config.adapter is present it is selected verbatim (identity preserved)
and no URL is read or validated. When it is absent, the URL is resolved
(valkeyUrl → VALKEY_URL, and it must be redis:// or rediss://) and the
built-in createBullMQAdapter is constructed from it — lazily, so no connection
opens at import.
Schedules
Each schedule spec is { id, job, cron | everyMs, timezone?, data? } — exactly
one of cron or everyMs — and is validated locally before any provider call.
Queue defaults are 3 attempts with exponential backoff (1000 ms); MAX_ATTEMPTS
is 25.
Scheduling is at-least-once: there is no exactly-once, non-overlap, or catch-up guarantee. Make handlers idempotent.
Running jobs
jsails work --config jsails.runtime.js # worker + schedule registration
jsails schedule --config jsails.runtime.js # one-shot registration, then exit
work starts a worker and registers schedules; schedule performs a one-shot
registration and exits. Both drive the selected adapter through the same neutral
contract, not a separate built-in path.
queue is a read-only dashboard over the same runtime config. It builds the
neutral runtime, reads the queue's normalized metrics
(waiting/active/completed/failed/delayed) through the producer's
optional readCounts capability, and prints them. It never starts a worker,
registers schedules, or dispatches a job; a transport without a countable queue
fails with a value-free error rather than printing zeros.
jsails queue --config jsails.runtime.js
jsails queue --json --config jsails.runtime.js
Metrics and failed jobs
createJobMetrics(options?) builds an in-memory aggregator that measures
completed and failed jobs by name via snapshot() and can reset() on its own
cadence. recordCompleted and recordFailed are synchronous; errors carry only
the message, never a payload or stack trace.
createFailedJobStore(options?) holds a bounded ring buffer of value-free
FailedJobEntry records. retry(id, dispatch) calls an injected dispatch
function and removes the entry on success; on failure the entry stays. Both are
pure in-memory data structures with no external service dependency.
Next steps
- Services — the extension seam, service tokens, and plugins.