Create a Job Transport
Connect any queue or scheduler to your Kizuna jobs by implementing one function.
A transport carries a job from jobs.indexUser.queue(...) to the process that runs it.
Kizuna ships none, so you write one against the queue you run.
dispatch is the only method you must implement.
Two shapes
Push systems deliver by calling a URL: QStash, Cloud Tasks, SQS to Lambda. Point them at the run endpoint, POST /jobs/run, with { job, input } as the body. Kizuna already guards and validates it, so these need only dispatch.
Pull systems store work and a worker drains it: BullMQ, pg-boss, a table in your own database. These also implement start.
dispatch
| Field | What it is |
|---|---|
job | The job's dotted key, e.g. billing.reconcileInvoices. |
input | The validated input, or undefined. JSON-safe. |
runAt | Hold until then. Ignore unless you declare supports.runAt. |
dedupeKey | Deliver at most one message with this key. Needs supports.dedupe. |
retry | Attempts the job declared. Backoff is yours. |
Resolve once the queue has accepted the message, which is before the job runs. Reject when it could not be handed over at all. That surfaces at the queue call site, wrapped in a JobDispatchError naming your transport and the job.
start
You get every job your API declares, so you can subscribe by name, and a run that invokes one through the same handler a route would reach.
Call run once per message, and let its rejection propagate, which is how your queue learns to retry. A swallowed rejection reads as a successful queue.
register
For a system with a scheduler of its own. startJobs then hands the schedules across instead of ticking them, and you own missed occurrences. Throw on a timezone you cannot express rather than dropping it.
supports
A message's runAt, dedupeKey and retry are honoured only where the transport declares support, under runAt, dedupe and retry. Say which you honour and Kizuna warns when a job asks for one you drop.
dedupe is what makes startJobs safe on more than one instance: every replica ticking 05:00 produces the same dedupeKey.
Example: QStash
QStash is a push transport. You publish to it with a destination URL, and it delivers your body to that URL verbatim, retrying on failure and holding messages until a delay elapses. It has a scheduler of its own, so it honours everything and implements register too.
Every message goes to the same URL, the run endpoint, and names its job in the body.
Two things that are easy to get wrong here, and both look like a delivered job that never ran:
upstash-retriesis off by one. Kizuna'sretryis a number of attempts; QStash's header is retries after the first.- The body is the message, not the input. QStash delivers what you publish verbatim, and the run endpoint reads
jobfrom it. Publishing the bare input leaves it with nothing to run.
register is worth implementing for QStash in particular: CRON_TZ= expresses a time zone, which platform cron cannot, since Vercel and GitHub read every expression as UTC. Note that a job you delete leaves its schedule behind, so prune what your config no longer declares.
Check the QStash docs for the current header set before relying on the names above.
Authenticating what QStash sends back
QStash calls the run endpoint, so the jobs' identity guards it. Forwarding the secret, as above, is the simplest thing that works: it arrives as Authorization and an ordinary bearer guard reads it, the same trust model as Vercel Cron's CRON_SECRET.
A guard cannot verify QStash's Upstash-Signature. The signature covers the raw body, and the request pipeline reads the body before
guards run, so by then it is parsed on Express and the stream is consumed on Hono and Next. Verify it ahead of Kizuna instead: framework
middleware on the run endpoint, or express.json({verify}) to keep the raw buffer for a guard to read.
Example: BullMQ
BullMQ is a pull transport, so it implements all three: dispatch adds to a Redis queue, start drains it, and register uses BullMQ's own repeatable jobs.
start is called by startJobWorker, not on its own. startJobWorker does, wherever you want the work done, whether that is the same process as the API or its own:
jobId is how BullMQ deduplicates, so dedupeKey maps onto it, but only while the job exists. Once it completes and is removed the id is free again, so supports.dedupe here is weaker than "never twice". register has the same edge in reverse, since changing a cron leaves the old repeatable job behind. Check the BullMQ docs for the repeatable-job API in the version you run.
What you can assume
inputis validated against the job's schema before you see it, and again when it runs, so a message that sat in your queue across a deploy is checked against the schema it lands on.jobis unique and stable, so it is safe as a queue name or routing key.
What you cannot
inputsurvives only as JSON. ADatedoes not come back aDate.- Delivery is at least once. Kizuna does not try to make it exactly once.