| name | lunora-setup-scheduler |
| description | Schedules deferred and recurring work in a Lunora app. Use for `ctx.scheduler.runAfter` / `runAt` (delayed function dispatch), cron jobs via `lunora registry add crons` (the `cronJobs()` builder), the `SchedulerDO` / `SCHEDULER` binding, retries, and the workpool for bounded concurrency. |
Lunora Setup Scheduler
Schedule work in Lunora two ways, both backed by @lunora/scheduler (re-exported
from @lunora/server):
- Deferred dispatch —
ctx.scheduler.runAfter / runAt from any function,
to run a function later. Built into the runtime; no registry install.
- Recurring jobs (crons) — declare them in
lunora/crons.ts with
cronJobs(). Add the starter with lunora registry add crons.
Both run jobs through the SchedulerDO Durable Object (binding SCHEDULER),
which owns the alarm and durable storage.
When to Use
- Running a function after a delay or at a specific time
(
runAfter / runAt).
- Declaring recurring jobs (sweep, digest, report) on a cron schedule.
- Bounding concurrency for many enqueued jobs (a workpool).
When Not to Use
- The project has no Lunora backend yet — use
lunora-quickstart first.
- You just need to react to live data changes — use a reactive
query /
subscription (lunora-realtime), not a scheduled job.
Deferred dispatch — runAfter / runAt
Available on ctx.scheduler in any function. Target functions are passed by
reference from the generated api / internal proxy:
import { mutation, v } from "@lunora/server";
import { internal } from "./_generated/api";
export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
const { id } = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
return { jobId: id };
});
runAfter(delayMs, fnRef, args, options?) — run after a delay (delayMs must
be a non-negative finite number). runAt(date, fnRef, args, options?) — run at
a Date or epoch-ms timestamp.
- Both return
{ id, scheduledFor }. Cancel with ctx.scheduler.cancel(id);
inspect with ctx.scheduler.get(id) / ctx.scheduler.list().
options accepts a retry policy ({ maxAttempts, backoff, baseMs, maxMs };
DO defaults: maxAttempts: 5, backoff: "exponential", baseMs: 30_000) and
a shardKey routing hint. On retry exhaustion the job is dead-lettered, never
silently dropped.
- The
SchedulerDO binding (SCHEDULER) is auto-inferred and reconciled
into wrangler.jsonc by @lunora/config once @lunora/scheduler is in use —
run lunora codegen / lunora doctor to confirm. Scheduled jobs run with no
end-user identity, so target internal functions (internal.*).
Recurring jobs — crons
Step 1: Add the starter
lunora registry add crons
This adds @lunora/server to package.json (run pnpm install) and copies
lunora/crons.ts (a cronJobs() registry with one illustrative job) and
lunora/crons/jobs.ts (the example run internal mutation it fires) into your
project — both yours to edit. No extra DO binding is required.
Step 2: Declare jobs
import { cronJobs } from "@lunora/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
crons.cron("custom", "0 */6 * * *", internal.foo.bar, {});
export default crons;
name must be a non-empty string literal, unique across the project.
fnRef must be a static two-segment access on the proxy
(internal.<file>.<fn> or api.<file>.<fn>) so codegen can discover it.
Cron targets must be internal functions — a client can never invoke them.
- All schedules are UTC and validated at definition time
(
hourUTC: 25 throws immediately).
Step 3: Regenerate types and the schedule
lunora codegen
Codegen discovers each registration by AST, compiles the schedule to a cron
expression, and emits lunora/_generated/crons.ts (the dispatcher the Worker's
scheduled() handler consumes) plus the matching triggers.crons entry in
wrangler.jsonc. You never hand-edit the wrangler schedule array.
Bounded concurrency — workpool (optional)
For many enqueued jobs that must not all run at once, use a workpool — a named
logical pool inside the same SchedulerDO (no extra binding):
import { createWorkpool } from "@lunora/scheduler";
const pool = createWorkpool({
namespace: env.SCHEDULER,
originUrl: "https://my-app.example.com",
name: "imports",
maxConcurrency: 3,
});
await pool.enqueue(internal.imports.processRow, { rowId });
The DO caps simultaneous dispatch at maxConcurrency and queues the rest
durably. A Cloudflare-Queues-backed variant (createQueueWorkpool) leans on
queue config for concurrency/retries instead — reach for the DO workpool when you
need per-job cancel / status.
Common Pitfalls
- Targeting a non-internal function from a job/cron. Scheduled dispatch has
no end-user identity — target
internal.* functions.
- Non-static
fnRef or name in cronJobs(). Codegen discovers them by
AST; a dynamic reference or computed name can't be found.
- Forgetting
lunora codegen after editing crons. The triggers.crons
array and the dispatcher map are codegen output — re-run it.
- Non-idempotent job handlers. A missed tick may be retried and a slow tick
can overlap the next — make handlers idempotent.
Checklist