@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
158 lines (118 loc) • 6.44 kB
Markdown
---
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:
```ts
import { mutation, v } from "@lunora/server";
import { internal } from "./_generated/api";
export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
// run an internal action 14 days from now
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
```bash
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
```ts
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, {}); // raw cron escape hatch
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
```bash
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):
```ts
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
1. **Targeting a non-internal function from a job/cron.** Scheduled dispatch has
no end-user identity — target `internal.*` functions.
2. **Non-static `fnRef` or `name` in `cronJobs()`.** Codegen discovers them by
AST; a dynamic reference or computed name can't be found.
3. **Forgetting `lunora codegen` after editing crons.** The `triggers.crons`
array and the dispatcher map are codegen output — re-run it.
4. **Non-idempotent job handlers.** A missed tick may be retried and a slow tick
can overlap the next — make handlers idempotent.
## Checklist
- [ ] Deferred work uses `ctx.scheduler.runAfter` / `runAt` against
`internal.*` functions.
- [ ] `SCHEDULER` (SchedulerDO) binding present in `wrangler.jsonc`
(`lunora doctor` clean) when using the scheduler.
- [ ] Recurring jobs declared in `lunora/crons.ts` via `cronJobs()` (after
`lunora registry add crons`).
- [ ] `lunora codegen` run so `_generated/crons.ts` + `triggers.crons` are
synced.
- [ ] Job handlers are idempotent.