Jobs
Run code once a write commits, after a delay, or on a schedule, retried until it succeeds, on every server.
@ultimabase/jobs runs code outside the request that asked for it. A run
is a row of the database, written in the write's transaction: it exists if
and only if the write committed, a crash or a restart does not lose it, and
it is retried until it succeeds. A job can also run every few minutes, or
on a cron pattern in a time zone. Every server runs the jobs, and each run
on one server at a time.
npm install @ultimabase/jobsA job takes a name, the input its runs take, and the function that runs them:
// The calls, whose video room closes in a job ten minutes after they end.
import { defineJob } from '@ultimabase/jobs'
import { defineModel } from 'ultimabase'
import { z } from 'zod'
import { closeVideoRoom } from './services.ts'
export const closeRoom = defineJob({
name: 'closeRoom',
// Validated when the run is enqueued, and stored with it.
input: z.object({ room: z.string() }),
// A run may run twice, after a crash: `id`, the run's, is the key that
// makes the service close the room once.
run: ({ input, id }) => {
closeVideoRoom(input.room, { key: id })
},
})The full example, with its tests: examples/jobs.
Enqueue a run
enqueue validates the input, then writes the run in the current
transaction, or alone outside one. From an
afterUpdate hook, the run exists if the update commits, and never after a
rollback. after delays it, in milliseconds:
export const Call = defineModel({
name: 'Call',
fields: (f) => ({
room: f.string(),
endedAt: f.dateTime({ optional: true }),
}),
}).with({
hooks: {
// In the write's transaction: the run exists if the update commits.
afterUpdate: async ({ record, previous }) => {
if (previous.endedAt !== null || record.endedAt === null) return
// In milliseconds: the room stays open ten minutes, to rejoin.
await closeRoom.enqueue({ room: record.room }, { after: 10 * 60 * 1000 })
},
},
})A run due now starts as its transaction commits, on the server that wrote
it; a delayed run starts within five seconds of its due time, on whichever
server finds it first. An invalid input is refused with
VALIDATION_FAILED, as a function's, and no run is written.
A *Commit hook runs once too, but in memory: a crash
before it ends loses it, and it never runs again. A job is for what must
happen, even after a crash.
Once or more
A run that throws runs again 10 seconds later, then twice as long each time.
After ten attempts, about 2 h 50 min, its row turns failed and is kept,
with its last error, for someone to read.
A run may also run twice: a server that crashes mid-run leaves it to
another five minutes later. A job that calls another service passes id,
the run's, as its idempotency key, or checks the state it changes. A run is
never cancelled: the job reads what is still true, as whether the call is
still over, when it runs.
Recurring jobs
A job with every runs every few milliseconds from the start; one with
cron runs on a five-field pattern, minute, hour, day of the month, month
and day of the week, in timeZone, UTC by default:
// Every hour from the start, in milliseconds.
export const purgeCalls = defineJob({
name: 'purgeCalls',
every: 60 * 60 * 1000,
run: async () => {
const before = new Date(Date.now() - 30 * day)
await Call.deleteMany(
{ where: { endedAt: { lt: before } } },
{ as: master },
)
},
})
// At 3:00 each night in Paris, daylight saving included; UTC by default.
export const nightlyReport = defineJob({
name: 'nightlyReport',
cron: '0 3 * * *',
timeZone: 'Europe/Paris',
run: async () => {
const since = new Date(Date.now() - day)
const ended = await Call.count(
{ where: { endedAt: { gte: since } } },
{ as: master },
)
outbox.push(`Calls ended yesterday: ${ended}.`)
},
})A recurring job takes no input and is never enqueued. Each has one row,
holding its next due time, so each occurrence runs on one server. While no
server ran, missed occurrences run once, not once each. A failure is
logged at error, and the next occurrence runs as planned.
Across daylight saving, a time the clocks skip runs at the first time after the gap, and a time they repeat runs once.
The plugin
jobsPlugin() takes every job the application runs:
export const app = defineApp({
// Kept in memory, so the example needs no database.
database: memory(),
// Listens on PORT, or 3000, and answers /health.
plugins: [
httpPlugin(),
jobsPlugin({ jobs: [closeRoom, purgeCalls, nightlyReport] }),
],
models: [Call],
})At start, it writes the rows of new recurring jobs, re-times those whose schedule changed, and deletes those of jobs no longer declared. Stopping, a server waits for the runs it started.
In a test
runJobs(), from @ultimabase/testing, runs every run due now, and the
runs they enqueue, as the servers would. The clock decides what is due:
vi.useFakeTimers({ toFake: ['Date'] }) moves the date alone, before the
application starts, so the recurring jobs are due from it:
// Before the application starts: its recurring jobs are due from now.
beforeEach(() => {
// Fakes the date alone, so the timers still run.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2027-01-04T08:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
testApplication(app)A delayed run is due once the test moves the clock past its delay:
const call = await Call.create({ data: { room: 'standup' } }, { as: master })
await Call.update(
{ where: { id: call.id }, data: { endedAt: new Date() } },
{ as: master },
)
// Runs what is due now, and what those runs enqueue: nothing yet.
await runJobs()
expect(closedRooms).toEqual([])
vi.setSystemTime(Date.now() + 10 * minute)
await runJobs()
expect(closedRooms).toEqual(['standup'])And a recurring job, once the clock reaches its time:
await Call.create(
{ data: { room: 'standup', endedAt: new Date() } },
{ as: master },
)
// 3:00 in Paris, in winter.
vi.setSystemTime(new Date('2027-01-05T02:00:00Z'))
await runJobs()
expect(outbox).toEqual(['Calls ended yesterday: 1.'])Reference
defineJob(definition)
| Key | Type | Effect |
|---|---|---|
name | string | The job's name, once in jobsPlugin()'s jobs. |
run | ({ id, input }) => unknown | Runs a run: id is the run's, input the validated input's. |
input | Standard Schema, as Zod's | The input of each run, validated by enqueue, stored as JSON. |
every | milliseconds | Runs every every milliseconds from the start. |
cron | five fields | Runs on the pattern. |
timeZone | IANA time zone, as 'Europe/Paris' | The zone of cron; 'UTC' by default. |
A job takes one of input, every and cron, or none. With input or
none, it has enqueue(input?, options?):
| Option | Type | Default | Effect |
|---|---|---|---|
after | milliseconds | none | How long the run waits. |
jobsPlugin(options)
| Option | Type | Effect |
|---|---|---|
jobs | defineJob values | Every job the application runs. |
It adds the model JobsRun, which no transport exposes: one row for each
pending or failed run, and one for each recurring job.
| Field | Effect |
|---|---|
job | The job's name. |
input | The validated input, as JSON. |
state | 'pending', or 'failed' after ten attempts. |
dueAt | When it runs next. |
attempts | The runs that failed. |
error | The last failure's message. |
lockedUntil | Until when a server holds it. |
recurring | A recurring job's name, on its one row. |
schedule | A recurring job's every or cron, to tell it changed. |
runJobs()
Runs, during a test, every run due now and the runs they enqueue, then resolves.
Errors
| Message | When |
|---|---|
defineJob: name is a non-empty string. | At import. |
closeRoom: run is a function. | At import. |
closeRoom: a job takes one of input, every and cron. | At import. |
closeRoom: input is a Standard Schema. | At import. |
closeRoom: every is a positive number of milliseconds. | At import. |
closeRoom: cron is a pattern of five fields. | At import; a pattern croner refuses gives its own message. |
closeRoom: timeZone goes with cron. | At import. |
closeRoom: timeZone is an IANA time zone. | At import. |
jobsPlugin’s jobs is an array of defineJob values. | At start. |
jobsPlugin’s jobs name closeRoom twice. | At start. |
closeRoom: after is a positive number of milliseconds. | enqueue. |
closeRoom is not in jobsPlugin's jobs. | enqueue of a job the plugin was not given. |
closeRoom.enqueue needs an application with jobsPlugin() started. | enqueue before the start, or after the stop. |
runJobs() runs during a test of an application with jobsPlugin(). | runJobs() outside such a test. |
runJobs stopped after 100 passes: a job enqueues a run due at once each time it runs. | A job that enqueues itself, due now, on each run. |
Limits
- A run starts within five seconds of its due time, each server polling the database; a run due now starts as its write commits.
- A server runs 20 runs at a time, and holds each for five minutes: a run longer than that may start again on another server.
- Each run runs at least once, not exactly once: its
idis the idempotency key. - A run's input is stored as JSON: it holds what JSON holds, a date as its ISO string.
crontakes five fields, no seconds.