jobs
Named jobs for a Void app: schedules in a time zone, run from one Void cron
file every minute, with a lease per job, a run history, manual runs, fan-out
over a Void queue with a dead-letter queue, and a health check.
examples/crm uses it.
Void’s own crons/ files run in UTC, each on its own trigger, with nothing
in between: no lock, no history, no way to run one by hand, and a queue
consumer that fails for good loses the message. This plugin is the part of
Recranet’s console (apps/api/src/jobs, docs/jobs.md) that any app needs:
one * * * * * trigger, a list of jobs, and two tables.
Set up
Section titled “Set up”The tables, in db/schema.ts, then void db generate writes the migration:
export { jobLock, jobRun } from "@recranet/flare/jobs/schema"The jobs, in a file of your own:
import { defineFanOutJob, defineJob, defineJobs } from "@recranet/flare/jobs"
const reminders = defineJob({ name: "task-reminders", description: "Mails task reminders that came due.", schedule: "*/15 * * * *", run: async ({ scheduledTime }) => `${await sendDueReminders(scheduledTime)} sent`,})
export const jobs = defineJobs([reminders], { timezone: "Europe/Amsterdam" })One cron file runs them:
import { jobs } from "../lib/jobs.ts"
export const cron = "* * * * *"
export default jobs.scheduledrun returns a one-line summary for the history (or nothing); a throw is
recorded as the run’s error, logged, and doesn’t stop the other jobs. Each
minute the due jobs run side by side in that one invocation, then expired
leases and runs older than historyDays (30) are dropped.
defineJob |
|
|---|---|
name |
Unique: the lease’s name, the history’s key, the “Run now” path. Letters, digits, . : - _ |
schedule |
Five-field cron in timezone. None: the job only runs by hand |
timezone |
An IANA zone; default defineJobs’s, else UTC |
leaseMinutes |
How long a run holds the lease. Default 15, the longest a scheduled invocation may run |
options |
What a manual run may pass ({ domain: "…" }); anything else is refused with a 400 |
run(ctx) |
ctx: scheduledTime (the minute it fired for), manual, options, runId |
defineJobs(jobs, options) |
|
|---|---|
timezone |
Default zone. Default UTC |
queue |
The Void queue for fan-out units and queued manual runs (below) |
historyDays |
Run history kept. Default 30 |
Schedules and DST
Section titled “Schedules and DST”*, lists, ranges and steps; day-of-week 0-7 (0 and 7 are Sunday); with
both day fields restricted, a day matches either, as in cron. A schedule is
matched against the wall clock in its zone, so 0 7 * * * in
Europe/Amsterdam is 05:00 UTC in summer and 06:00 UTC in winter.
The two DST nights, for schedules with a restricted hour (30 2 * * *,
0 9-17 * * 1-5), go the way Vixie cron does them:
- A time the spring jump skips runs at the first minute after it: 02:30 on the last Sunday of March runs at 03:00 in Amsterdam.
- A time the autumn fall-back repeats runs once, the first time.
Schedules with every hour (*/15 * * * *, 5 * * * *) follow real time:
they run in both copies of the repeated hour and skip the hour that doesn’t
exist. isDue, nextDue and parseCron are exported for your own use.
Leases, history and stale runs
Section titled “Leases, history and stale runs”A run is a job_run row first (running), then a lease in job_lock: the
row’s id with lockedUntil leaseMinutes ahead. A tick that finds the
lease held is recorded as skipped (“Previous run still holds the lock”),
the way flock -n would. The run ends as succeeded with its summary or
failed with the first line of its error, and frees the lease.
A run whose Worker died before recording its end (an evicted isolate, a
deploy, a CPU limit) stays running with no live lease. list() and
runs() show it as stale. Its lease runs out after leaseMinutes, so the
job runs again on the tick after that. If it outlived its lease and still
finishes, it records its own ending over the stale one.
job_lock is also a place for one-off keys with a lease: the CRM’s digest
claims task-digest:<organization>:<person>:<day> before mailing, so a
retried unit doesn’t mail anyone twice. Expired keys are pruned with the
leases.
Manual runs
Section titled “Manual runs”await jobs.runJob("task-reminders") // in this invocation; returns { id, status, message }await jobs.startJob("task-reminders", { waitUntil: (p) => c.executionCtx.waitUntil(p) }) // records and locks, then queues itFor a “Run now” button, a one-line route file:
import { access } from "../../../../lib/access.ts"import { jobs } from "../../../../lib/jobs.ts"
export const POST = jobs.runRoute((c) => access.requireMember(c, { job: ["run"] }))The guard is any function that throws to refuse; access’s
requireMember answers 401 and 403. A JSON body is the run’s options. The
route answers 202 with { id, status, message }: queued when the run went
to the queue, skipped when another run holds the lease, 404 for an unknown
job and 400 (INVALID_OPTIONS) for an option the job doesn’t take.
With queue set, the request records the run and takes the lease, and the
queue consumer runs it, so a manual run gets a consumer invocation’s 15
minutes. Without one it runs in the request’s waitUntil, which lasts 30
seconds past the response. A fan-out job’s manual run plans in the request
and queues its units; with its unitOption ({ organization: "…" }) it
queues that one unit and skips the plan.
Manual runs are marked manual and keep their options. For health, a
manual success without options counts as the scheduled run would (after a
fix, one run by hand clears the alert); a manual failure, or a run with
options, doesn’t.
Fan-out over a queue
Section titled “Fan-out over a queue”A job too big for one invocation (an API call per tenant, more than D1’s 1000 queries) splits into a plan and units:
const digest = defineFanOutJob({ name: "task-digest", schedule: "0 7 * * *", unitOption: "organization", plan: async ({ scheduledTime }) => organizationsWithTasksDue(scheduledTime), // string[] or { units, note } runUnit: (organizationId, ctx) => sendDigest(organizationId),})
export const jobs = defineJobs([reminders, digest], { timezone: "Europe/Amsterdam", queue: "jobs" })The scheduled run calls plan and sends one message per unit (in batches
of 100) to the queue; its summary is “Queued 12 units”. Each unit is its own
job_run row (unit, attempt) under the lease <job>:<unit>. A unit
that throws is handed back to the queue, which retries it max_retries
times and then moves it to the dead-letter queue. Return a summary instead
for what a retry can’t fix. A unit whose previous delivery still holds the
lease is acked as skipped.
The queue is two Void consumer files, and the dead-letter queue goes in
void.config.ts:
import { defineQueue } from "void"import type { JobMessage } from "@recranet/flare/jobs"import { jobs } from "../lib/jobs.ts"
export const maxBatchSize = 1 // a unit per invocation, each with the full 15 minutesexport const maxRetries = 2
export default defineQueue<JobMessage>(jobs.consume)export default defineQueue<JobMessage>(jobs.deadLetters)cloudflare: { queues: { consumers: [{ queue: "jobs", max_batch_size: 1, max_retries: 2, dead_letter_queue: "jobs-dlq" }] },},jobs.deadLetters records each message that ran out of retries as a failed
run, “Gave up after 3 attempts: wrangler queues to look at them.
What Void doesn’t do, and what the plugin does about it
Section titled “What Void doesn’t do, and what the plugin does about it”- No dead-letter queue. Void writes the consumer for
queues/jobs.tswithout one and has no setting for it. Thecloudflareblock’s entry above is passed through as a second consumer entry for the same queue, sodist/ssr/wrangler.jsonlistsjobstwice. Keep the two entries’ other settings the same (maxBatchSize/max_batch_size,maxRetries/max_retries). Locally the last entry wins, so miniflare retries and dead-letters as configured (the CRM’s tests check it). Onvoid deployWrangler sends one consumer request per entry, so which one Cloudflare ends with isn’t defined; this hasn’t been tried on an account. After a deploy, check withwrangler queues consumer list jobs; if the dead-letter queue is missing,wrangler queues consumer remove jobs <worker>andwrangler queues consumer add jobs <worker> --batch-size 1 --message-retries 2 --dead-letter-queue jobs-dlq. A later deploy may undo it. - No ExecutionContext in queue consumers. Void calls a consumer with
(batch, env). The plugin doesn’t need one: every run, unit and bit of bookkeeping is awaited before the consumer returns, and Void acks or retries each message after it does. The run context has nowaitUntileither, in the cron as in the queue, so a job behaves the same in both. Await whatever a run starts; a promise left floating may be cut off when the invocation ends. OnlystartJobtakes awaitUntil, from the request, for a manual run without a queue. - Retries go by the queue. Void acks or retries per message after the
handler returns (
retryDelay/retry_delaysets the wait). The consumer decides for every message, so one failed unit doesn’t retry the rest of a batch.
Health
Section titled “Health”const unhealthy = await jobs.health() // [{ name, lastSuccessAt, thresholdMinutes }]A scheduled job is reported once it has gone without a success longer than four of its scheduled runs, at most a day, but never less than its longest gap between runs plus an hour: an every-15-minutes job after an hour, a daily one after 25 hours, a weekly one an hour after the run it missed. A job that has run but never succeeded counts from its first run; one that never ran isn’t reported. Fan-out units aren’t in it: a unit that fails for good shows up as a dead-lettered run instead. Failures the next run makes up for are never reported.
Mail it from a job of your own (daily, with the list), or show it: list()
marks each job healthy. Health runs on the same cron trigger, so it can’t
notice the trigger itself stopping.
A jobs page
Section titled “A jobs page”jobs.list() gives every job with its schedule, zone, next run, last run
(stale included), last success and health; jobs.runs({ job, limit }) the
latest runs, units included. The CRM’s pages/jobs.vue shows both with a
“Run now” per job, for owners only (job: ["list", "run"] in
lib/access.ts, held by the owner role alone): the jobs run for the whole
Worker, not for one organization, so its other roles neither see nor start
them. A unit that names an organization is shown by name for the owner’s
own and as “Another organization” otherwise. In an app with a platform
tier, gate on access.requireAdmin instead.
Locally
Section titled “Locally”vite dev doesn’t fire crons. Run the minute you want:
curl -X POST http://localhost:5174/__void/scheduled \ -H "content-type: application/json" -H "x-void-dev-trigger: $(cat .void/dev-trigger-token)" \ -d '{"cron":"* * * * *","scheduledTime":'"$(date +%s000)"'}'Queue messages are delivered by miniflare as they’re sent.
- The module imports
void/db,void/queuesandvoid/schema-d1(an optional peer dependency) andhonotypes; nothing else from Flare. - Run ids are integers in
job_run; don’t show them in a page. - What’s left out from the console: per-tenant jobs (an app picks its list
in
defineJobs), D1 row counts per run, and mailing the health report (a job of the app’s own).