Skip to content

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.

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:

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

crons/jobs.ts
import { jobs } from "../lib/jobs.ts"
export const cron = "* * * * *"
export default jobs.scheduled

run 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

*, 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.

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.

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 it

For a “Run now” button, a one-line route file:

routes/api/jobs/[name]/run.ts
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.

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:

queues/jobs.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 minutes
export const maxRetries = 2
export default defineQueue<JobMessage>(jobs.consume)
queues/jobs-dlq.ts
export default defineQueue<JobMessage>(jobs.deadLetters)
void.config.ts
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: ”, and acks it, so the history and a jobs page show it. Without that file nothing reads the dead-letter queue; its messages wait there for 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.ts without one and has no setting for it. The cloudflare block’s entry above is passed through as a second consumer entry for the same queue, so dist/ssr/wrangler.json lists jobs twice. 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). On void deploy Wrangler 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 with wrangler queues consumer list jobs; if the dead-letter queue is missing, wrangler queues consumer remove jobs <worker> and wrangler 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 no waitUntil either, 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. Only startJob takes a waitUntil, 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_delay sets the wait). The consumer decides for every message, so one failed unit doesn’t retry the rest of a batch.
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.

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.

vite dev doesn’t fire crons. Run the minute you want:

Terminal window
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/queues and void/schema-d1 (an optional peer dependency) and hono types; 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).