@lunora/scheduler ships a SchedulerDO you mount once per app. It stores
pending invocations sorted by scheduled time and fires them via HTTP on the DO
alarm. On top of that it gives you deferred jobs (runAfter/runAt), code-first
cron jobs, and two workpool flavours for bounded-concurrency background work.
Deferred jobs
Inside a mutation or action, schedule a function by path through ctx.scheduler
(codegen wires this surface):
import { mutation, v } from "@/lunora/_generated/server";
export const requestExport = mutation.input({ workspaceId: v.id("workspaces") }).mutation(async ({ ctx, args: { workspaceId } }) => {
const id = await ctx.scheduler.runAfter(5 * 60_000, "exports:run", { workspaceId });
return id;
});ctx.scheduler exposes runAfter, runAt, cancel, get, and list.
runAfter/runAt take the function path as a string (e.g. "exports:run")
and return the job id.
Under the hood ctx.scheduler.runAfter POSTs to the SchedulerDO's /schedule
endpoint with functionPath, args and scheduledFor. The origin it is called
back on is NOT on that request: the DO reads it from its own
env.LUNORA_ORIGIN_URL, because a caller-supplied dispatch target would be an
SSRF vector. Set that var (in vars for local dev, as a secret in production) or
every schedule is refused with ORIGIN_NOT_CONFIGURED.
Standalone scheduler
Outside a Lunora function (a plain Worker, a test), build the client yourself
with createScheduler. This surface takes a typed function reference (from
_generated/api) instead of a path:
import { createScheduler } from "@lunora/scheduler";
import { api } from "@/lunora/_generated/api";
const scheduler = createScheduler({
namespace: env.SCHEDULER, // SchedulerDO binding
});
const id = await scheduler.runAfter(5 * 60_000, api.email.sendReminder, { userId: "u-1" });
await scheduler.runAt(new Date("2026-06-01T12:00:00Z"), api.cleanup.run, { older: 30 });
await scheduler.cancel(id);runAfter/runAt resolve the job id — the same string cancel, get and
ctx.scheduler deal in. Read the instant it will fire at back off
scheduler.get(id).
Pass instanceName to isolate a tenant's jobs into a separate DO instance.
Per-job RunOptions cover shardKey (routing hint) and retry, a retry
policy with maxAttempts (default 5 — retries after the first dispatch, so
six deliveries in all), backoff ("exponential" | "linear"),
baseMs (default 30_000), and an optional maxMs ceiling. On exhaustion the job
is parked under a dead-letter key rather than dropped.
Cron jobs
Declare recurring jobs code-first in lunora/crons.ts with cronJobs(). Codegen
discovers the file, compiles each schedule to a cron expression, emits the
wrangler.jsonc triggers.crons array, and wires the runtime dispatch map, so you
never edit wrangler by hand:
import { cronJobs } from "@lunora/scheduler";
import { internal } from "@/lunora/_generated/api";
const crons = cronJobs();
crons.interval("clear presence", { minutes: 30 }, internal.presence.clear, {});
crons.hourly("sweep sessions", { minuteUTC: 17 }, internal.presence.sweep, {});
crons.daily("send digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
crons.weekly("weekly report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
crons.monthly("monthly invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
crons.cron("custom", "0 * * * *", internal.foo.bar, {});
export default crons;Each method takes a unique name, a schedule, a target, and optional args.
Names must be unique within one cronJobs() registry. The target may be a
function reference (a one-shot dispatch) or a durable workflow reference
(workflows.<name>). A workflow target starts a fresh workflow instance on each
fire, and its args are type-checked against the workflow's params.
interval takes exactly one of {minutes | hours}. { seconds } is rejected at definition time: Cloudflare Cron Triggers have a one-minute floor,
and the 6-field expression it would compile to is refused by wrangler deploy. For sub-minute recurrence use ctx.scheduler.runAfter/runAt (via a
workpool for bounded concurrency); { minutes: 1 } is the fastest cron-native cadence. hourly, daily, weekly, and monthly schedule at a fixed UTC
wall-clock time. Use .cron for the raw 5- or 6-field grammar when the ergonomic forms don't fit.
Migrating from Convex: { hours: 24 } is not "once a day". An interval compiles to a cron step within its field's period, so interval.hours is capped
at 23 and must divide 24: */24 in a 0-23 field is not a recurrence. { days: 1 } is not a unit either. Use crons.daily(name, { hourUTC, minuteUTC }, …) instead.
Prefer hourly/daily over interval generally: they let you place a job off the boundary, which is how you stop a dozen jobs from all firing at :00.
Imperative cron triggers
If you'd rather emit a wrangler.jsonc fragment yourself, createCronTrigger
returns the snippet and dispatcher metadata for a single recurring function:
import { createCronTrigger } from "@lunora/scheduler";
import { internal } from "@/lunora/_generated/api";
const trigger = createCronTrigger({
schedule: "0 3 * * *",
fn: internal.cleanup.cleanupOldMessages,
});
// trigger.crons → ["0 3 * * *"]
// trigger.wranglerJsonc → the JSON snippet to paste under triggers.crons
// trigger.dispatcher → { functionPath, args }Both surfaces validate the expression eagerly via cron-parser, so a malformed
schedule throws at authoring time. isValidCronExpression /
assertValidCronExpression are exported if you need the check standalone.
Workpools
For bounded-concurrency background work, createWorkpool builds a named logical
pool inside the same SchedulerDO, with no extra binding. The DO dispatches at most
maxConcurrency of the pool's jobs at once and queues the rest durably, draining
as the runtime reports completions:
import { createWorkpool } from "@lunora/scheduler";
import { internal } from "@/lunora/_generated/api";
const pool = createWorkpool({
namespace: env.SCHEDULER,
maxConcurrency: 5,
name: "stripe-sync",
});
const { id } = await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
const { inFlight, queued, maxConcurrency } = await pool.status();
await pool.cancel(id);Reach for the DO-backed createWorkpool when you need a hard concurrency cap,
per-job cancellation, or per-job status. When you only want to rate-limit
fire-and-forget work, createQueueWorkpool leans on Cloudflare Queues instead:
concurrency, retries, and dead-lettering are configured on the consumer in
wrangler.jsonc (max_concurrency, max_retries, dead_letter_queue):
import { createQueueConsumer, createQueueWorkpool, httpDispatcher } from "@lunora/scheduler";
import { internal } from "@/lunora/_generated/api";
// Producer (inside an action / Worker).
const queue = createQueueWorkpool({ queue: env.JOBS });
await queue.enqueue(internal.images.optimize, { key: "u-1/avatar.png" });
// Consumer (your Worker's queue() handler).
export const queueHandler = createQueueConsumer({
dispatch: httpDispatcher({ originUrl: "https://app.acme.test", adminToken: env.LUNORA_ADMIN_TOKEN }),
});The Queues-backed pool has no hard concurrency cap, per-job cancel, or per-job status; that's the trade for letting Cloudflare own retries and backoff.
Neither workpool is for multi-step orchestration. Reach for Cloudflare Workflows (@lunora/workflow) when you need durable step.do / step.sleep /
step.waitForEvent.
Dispatch contract
When the alarm fires the DO POSTs to
${env.LUNORA_ORIGIN_URL}/_lunora/scheduler/dispatch with
{ functionPath, args, shardKey, scheduledFor, id }. The runtime unwraps
that and runs the function on the same code path as an RPC call — with one
difference: the dispatch is server-initiated, so it carries no end-user
identity. ctx.auth.userId is null and ctx.ip is undefined, which is also
what lets a job target an internal function. Pass anything the job needs to
know about a user in its args; never gate a scheduled function on ctx.auth.
A cron trigger takes a different route: the worker's scheduled() handler
dispatches it straight to the shard, without touching the SchedulerDO. So the
retry ladder, backoff, and dead-letter park above apply to runAfter/runAt
jobs only — a failed cron tick is logged as CRON_JOB_FAILED and not retried.