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/jobs

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

KeyTypeEffect
namestringThe job's name, once in jobsPlugin()'s jobs.
run({ id, input }) => unknownRuns a run: id is the run's, input the validated input's.
inputStandard Schema, as Zod'sThe input of each run, validated by enqueue, stored as JSON.
everymillisecondsRuns every every milliseconds from the start.
cronfive fieldsRuns on the pattern.
timeZoneIANA 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?):

OptionTypeDefaultEffect
aftermillisecondsnoneHow long the run waits.

jobsPlugin(options)

OptionTypeEffect
jobsdefineJob valuesEvery 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.

FieldEffect
jobThe job's name.
inputThe validated input, as JSON.
state'pending', or 'failed' after ten attempts.
dueAtWhen it runs next.
attemptsThe runs that failed.
errorThe last failure's message.
lockedUntilUntil when a server holds it.
recurringA recurring job's name, on its one row.
scheduleA 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

MessageWhen
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 id is the idempotency key.
  • A run's input is stored as JSON: it holds what JSON holds, a date as its ISO string.
  • cron takes five fields, no seconds.

On this page