Skip to content
DocspackagesDocumentation

@lunora/scheduler

Durable Object-backed scheduling for Lunora — runAfter/runAt, code-first cron jobs, and bounded-concurrency workpools.

PackagesScheduler

@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.