Jobs
Declare background and scheduled jobs next to their handlers, run them or queue them from your own code, and connect any queue you already run.
Jobs are new and still settling. The shape of k.jobs, the handler signature, and the job endpoints may change while v2 is in beta, so pin your version if you depend on them.
k.jobs declares background work beside your routes, each job carrying the handler that runs it.
Declare the jobs
k.job takes what the job is, .handler() takes what runs it, and k.jobs groups them under the identity every one of them requires.
import { z } from 'zod';
import { cron } from 'kizunajs';
import { k } from './k';
export const jobs = k.jobs('scheduler', {
sendDigests: k
.job({
schedule: cron.daily('05:00'),
summary: 'Send the daily digest to every user',
result: z.object({
sent: z.int(),
}),
})
.handler(async () => ({
status: 200,
body: {
sent: await sendPendingDigests(),
},
})),
indexUser: k
.job({
retry: 3,
input: z.object({
userId: z.string(),
}),
})
.handler(async ({ input, throwError }) => {
const user = await db.users.findById(input.userId);
if (!user) {
throwError({
status: 422,
body: {
detail: `No user with id ${input.userId}`,
},
});
}
await search.index(user);
}),
});A handler receives input, throwError, and jobs. A job with no declared result can return nothing.
A job with a schedule runs on a clock; one without is only ever queued. A job can have both.
Jobs nest in groups:
export const jobs = k.jobs('scheduler', {
billing: {
reconcileInvoices: k
.job({
schedule: cron.every('15m'),
})
.handler(async () => ({
status: 200,
body: {
reconciled: await reconcile(),
},
})),
},
});Name them on your config
import { jobs } from './src/jobs';
import { scheduler } from './src/identities';
export default defineConfig({
adapter: expressAdapter(),
routes,
jobs,
auth: {
identities: {
scheduler,
},
},
});The identity k.jobs names carries its own guard, so nothing else is wired here.
Schedules
A five-field cron expression, read as UTC, or an object to read it in a time zone:
schedule: '0 5 * * *';
schedule: {
cron: '0 3 * * *',
timezone: 'Europe/Oslo',
}Helpers return plain cron strings:
| Helper | Expression |
|---|---|
cron.every('15m') | */15 * * * * |
cron.every('2h') | 0 */2 * * * |
cron.hourly(30) | 30 * * * * |
cron.daily('05:00') | 0 5 * * * |
cron.weekly('mon', '09:00') | 0 9 * * 1 |
cron.monthly(1, '05:00') | 0 5 1 * * |
An invalid expression throws when the config is assembled, naming the field.
Return status and retries
| Return | Status | Read as |
|---|---|---|
| success | 200, or 204 with no result | done |
| transient failure | 503 | retry me |
| permanent failure | 422 | do not retry; page a human |
| an unexpected throw | 500 | retry me |
Running and queueing
Every handler receives a jobs runner shaped like the declaration, with two methods per job:
createUser: k
.route({
method: 'POST',
path: '/users',
body: CreateUserSchema,
responses: {
201: UserSchema,
},
})
.handler(async ({ body, jobs }) => {
const user = await db.users.create(body);
await jobs.indexUser.queue({
input: {
userId: user.id,
},
});
return {
status: 201,
body: user,
};
}),// Run it now, block, get the result.
const result = await jobs.billing.reconcileInvoices.run({ since });
// Put it in line and answer the request.
await jobs.search.reindex.queue();queue takes a message:
await jobs.chargeInvoice.queue({
input: {
invoiceId,
},
dedupeKey: `charge:${invoiceId}`,
runAt: threeDaysFromNow,
});Input is validated against the job's input schema either way.
A job's handler receives the same runner, so a scheduled job can queue one job per unit of work rather than doing all of it inline.
Setting it up
pnpm add kizunajs@betabun add kizunajs@betanpm install kizunajs@betaNo queue. startJobs watches the clock, and a queued job runs right here.
A job that fails after the response has gone needs somewhere to report:
export default defineConfig({
adapter: expressAdapter(),
routes,
jobs,
jobRunner: {
onError: (job, error) => Sentry.captureException(error, { tags: { job } }),
},
});import { startJobs } from 'kizunajs/jobs';
import kizuna from '../kizuna.config';
kizuna.api.mount(app);
startJobs(kizuna.api);Run one instance. Every replica ticks its own schedule, and no dedupeKey is honoured, so two replicas mean two runs.
The same startJobs, plus a queue holding the jobs you call queue() on, so a restart does not lose them.
export default defineConfig({
adapter: expressAdapter(),
routes,
jobs,
jobRunner: {
transport: bullmq(connection),
},
});kizuna.api.mount(app);
startJobs(kizuna.api);If your queue hands work back rather than calling your job's URL, run a worker to pull from it:
import { startJobWorker } from 'kizunajs/jobs';
import kizuna from '../kizuna.config';
const worker = await startJobWorker(kizuna.api);
process.on('SIGTERM', () => void worker?.stop());More than one instance is fine, as long as the queue drops repeated keys. You can also drop startJobs and hand the schedules to the queue's own scheduler with register.
Vercel, Lambda, Cloudflare. Platform cron ticks the dispatch endpoint and that covers every schedule:
export default defineConfig({
adapter: nextAdapter(),
routes,
jobs,
auth: {
identities: {
scheduler,
},
},
jobRunner: {
method: 'GET',
},
});{
"crons": [
{
"path": "/jobs/dispatch",
"schedule": "* * * * *"
}
]
}One entry covers every job you ever add: the endpoint works out which are due. method: 'GET' is there because Vercel Cron issues GET.
queue needs a transport here, because the function can be frozen the moment it answers. Use a queue that delivers over HTTP, like QStash, Cloud Tasks, or SQS. It posts to the run endpoint, which the same identity guards.
export default defineConfig({
adapter: nextAdapter(),
routes,
jobs,
jobRunner: {
transport: qstash({
token: process.env.QSTASH_TOKEN,
baseUrl: 'https://api.example.com',
schedulerSecret: process.env.CRON_SECRET,
}),
},
});Transports
A transport carries a queued job out of this process to whatever runs it. Name one under jobRunner and your existing queue calls become as durable as it is:
export default defineConfig({
adapter: expressAdapter(),
routes,
jobs,
jobRunner: {
transport: myQueue,
},
});Kizuna ships no transports. QStash, BullMQ, pg-boss, Cloud Tasks, SQS, or a table in your own database all fit the same interface: see Create a job transport.
A job is addressed by its dotted key, so a transport carries the name and the input. Queues come in two shapes, and the shape decides whether you run anything extra:
| Shape | Examples | You run |
|---|---|---|
| Delivers over HTTP | QStash, Cloud Tasks, SQS to Lambda | nothing, the run endpoint is already there |
| Hands the work back | BullMQ, pg-boss, your own table | startJobWorker(api) |
startJobWorker returns undefined against a queue that needs no worker. Run it in the API's process or its own.
retry: 3 on a job says how many attempts a failure deserves; your transport does the retrying. Kizuna warns when a job asks for something its transport will drop, at startup for retry and at the queue call for runAt and dedupeKey.
The two job endpoints
api.mount serves two endpoints for every job. Both mount as ordinary routes, so the jobs' identity guards them and failures render as Problem Details.
| Endpoint | Called by | Body | Answers |
|---|---|---|---|
POST /jobs/dispatch | platform cron | none | 200, or 503 with the names that failed |
POST /jobs/run | your transport | { job, input } | whatever the job answered, 404 for a name it does not know |
POST /jobs/dispatch runs whichever jobs the elapsed window belonged to. Point platform cron at it.
POST /jobs/run runs the one job the body names, validating input against that job's schema and answering 422 when it does not fit. It answers with the job's own status, which a queue reads as the retry signal.
Configure both under jobRunner on defineConfig, beside the transport and onError above:
| Option | Default | Description |
|---|---|---|
path | /jobs | The namespace both are mounted under |
method | POST | /jobs/dispatch's method. 'GET' for Vercel Cron |
windowMs | 60000 | How far back a tick looks for due jobs |
only | none | Dispatch only these jobs |
exclude | none | Never dispatch these |
method moves /jobs/dispatch only: /jobs/run takes a body, so it is always POST. Nothing is served on /jobs itself.
A tick runs each due job inline, in the request the scheduler made, so the function's timeout is the budget for all of them. Keep the tick short by having a scheduled job queue one job per unit of work:
export const jobs = k.jobs('scheduler', {
sendDigests: k
.job({
schedule: cron.daily('05:00'),
result: z.object({
queued: z.int(),
}),
})
.handler(async ({ jobs }) => {
const due = await db.users.needingDigest();
for (const user of due) {
await jobs.sendOneDigest.queue({
input: {
userId: user.id,
},
dedupeKey: `digest:${user.id}:${today()}`,
});
}
return {
status: 200,
body: {
queued: due.length,
},
};
}),
sendOneDigest: k
.job({
input: z.object({
userId: z.string(),
}),
})
.handler(async ({ input }) => {
await mailer.sendDigest(input.userId);
}),
});Local development
When you deploy to platform cron and want pnpm dev to exercise the real HTTP path, startJobsDevRunner ticks the dispatch endpoint over the network on an interval, the way your platform cron will:
import { startJobsDevRunner } from 'kizunajs/jobs';
if (process.env.NODE_ENV !== 'production') {
startJobsDevRunner(kizuna.api, {
baseUrl: `http://localhost:${port}`,
secret: process.env.CRON_SECRET,
});
}What Kizuna does not do
Kizuna owns no database and no queue, so it does not:
- Store your jobs. Durability, dead-lettering, and a dashboard come from your transport.
- Retry. Your transport does. Without one, nothing retries.
- Know whether a job already ran. It only names the run so a deduplicating transport can tell.
- Prevent overlapping runs. Take a lock in your own database.
- Keep run history or backfill missed ticks.