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.

Alpha

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.

src/jobs.ts
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:

src/jobs.ts
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

kizuna.config.ts
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:

HelperExpression
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

ReturnStatusRead as
success200, or 204 with no resultdone
transient failure503retry me
permanent failure422do not retry; page a human
an unexpected throw500retry me
Job handlers must be idempotent. Delivery is at least once everywhere, so a retry runs your handler again.

Running and queueing

Every handler receives a jobs runner shaped like the declaration, with two methods per job:

src/routes/users.ts
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@beta
bun add kizunajs@beta
npm install kizunajs@beta

No 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:

kizuna.config.ts
export default defineConfig({
    adapter: expressAdapter(),
    routes,
    jobs,
    jobRunner: {
        onError: (job, error) => Sentry.captureException(error, { tags: { job } }),
    },
});
src/index.ts
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.

kizuna.config.ts
export default defineConfig({
    adapter: expressAdapter(),
    routes,
    jobs,
    jobRunner: {
        transport: bullmq(connection),
    },
});
src/index.ts
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:

worker.ts
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:

kizuna.config.ts
export default defineConfig({
    adapter: nextAdapter(),
    routes,
    jobs,
    auth: {
        identities: {
            scheduler,
        },
    },
    jobRunner: {
        method: 'GET',
    },
});
vercel.json
{
    "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.

kizuna.config.ts
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:

kizuna.config.ts
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:

ShapeExamplesYou run
Delivers over HTTPQStash, Cloud Tasks, SQS to Lambdanothing, the run endpoint is already there
Hands the work backBullMQ, pg-boss, your own tablestartJobWorker(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.

EndpointCalled byBodyAnswers
POST /jobs/dispatchplatform cronnone200, or 503 with the names that failed
POST /jobs/runyour 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:

OptionDefaultDescription
path/jobsThe namespace both are mounted under
methodPOST/jobs/dispatch's method. 'GET' for Vercel Cron
windowMs60000How far back a tick looks for due jobs
onlynoneDispatch only these jobs
excludenoneNever 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:

src/jobs.ts
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:

src/index.ts
import { startJobsDevRunner } from 'kizunajs/jobs';

if (process.env.NODE_ENV !== 'production') {
    startJobsDevRunner(kizuna.api, {
        baseUrl: `http://localhost:${port}`,
        secret: process.env.CRON_SECRET,
    });
}
The dev runner ticks on an interval held in memory, so a tick while the process is down is missed silently.

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.

On this page