Skip to content
DocsconceptsDocumentation

Scheduling

Defer follow-up work with ctx.scheduler.runAfter / runAt, and run recurring jobs via Cron Triggers.

Last updated:

Not all work should happen inside the request that triggers it. Sending a welcome email, expiring a draft, reconciling with Stripe, or sweeping stale rows all want to happen later: after the mutation commits, on a delay, or on a recurring schedule. @lunora/scheduler covers both shapes. Deferred jobs run through ctx.scheduler, recurring jobs through Cron Triggers, both backed by a SchedulerDO you mount once per app.

Deferring a function

ctx.scheduler is available on MutationCtx and ActionCtx (not on a read-only QueryCtx). It enqueues a function to run later:

  • ctx.scheduler.runAfter(delayMs, target, args?) runs after a delay.
  • ctx.scheduler.runAt(timestampMs, target, args?) runs at an absolute epoch-millisecond time.

Both return the scheduled job's id (a string) so you can track or cancel it. The target is either a generated reference (internal.notifications.notifySubscribers, api.…) or the equivalent "namespace:function" path string, and it may be a mutation or an action — not a query. A reference is the better habit: it is checked against what actually exists, where a path string is not. Internal functions, which aren't exposed to clients, are the usual choice for follow-up work.

import { internal } from "@/lunora/_generated/api";
import { mutation, v } from "@/lunora/_generated/server";

export const publishPost = mutation.input({ postId: v.id("posts") }).mutation(async ({ ctx, args: { postId } }) => {
    await ctx.db.patch(postId, { status: "published" });

    // Fire-and-forget follow-up: notify subscribers 30s later.
    await ctx.scheduler.runAfter(30_000, internal.notifications.notifySubscribers, { postId });
});

runAt is the same idea pinned to a wall-clock time, e.g. expire a trial at a stored deadline:

await ctx.scheduler.runAt(trial.endsAt, "billing:expireTrial", { userId });

Scheduling an agent or workflow

runAfter / runAt also accept a generated durable-target reference (agents.<name> from @lunora/agent or workflows.<name> from @lunora/workflow) instead of a "namespace:function" path. Each fire starts a fresh durable instance of that agent/workflow, so a one-shot delayed agent run is just a scheduled target:

import { mutation, v } from "@/lunora/_generated/server";
import { agents } from "@/lunora/_generated/api";

export const ticketOpened = mutation.input({ ticketId: v.id("tickets") }).mutation(async ({ ctx, args: { ticketId } }) => {
    await ctx.db.patch(ticketId, { state: "open" });

    // Kick off a durable support-agent run five minutes from now.
    await ctx.scheduler.runAfter(5 * 60_000, agents.support, { prompt: "Draft a first reply", ticketId });
});

The second argument's shape decides the dispatch: a string path runs a Lunora function, a generated agents.<name> / workflows.<name> reference starts a new durable instance with the args as its run input. For a recurring agent, give cronJobs() the same agents.<name> target; see recurring jobs below.

Scheduling from a mutation vs. an action

The two contexts schedule identically, but the intent differs:

  • From a mutation, scheduling moves a side effect out of the transactional read/write scope. A mutation can't call fetch() or write to R2, so it schedules an action to do that once the write has committed. The job is held until the commit and dropped if the mutation rolls back, so it can never fire for a write that never landed — you still get the job id back synchronously, so you can store it on the row and cancel it later.
  • From an action, scheduling chains further async work: retry an external call later, or fan a long job out into stages. An action has no transaction to wait for, so its jobs are enqueued immediately.
import { mutation, v } from "@/lunora/_generated/server";

export const orderPlaced = mutation.input({ orderId: v.id("orders") }).mutation(async ({ ctx, args: { orderId } }) => {
    await ctx.db.patch(orderId, { state: "placed" });
    // The action does the external work the mutation isn't allowed to.
    await ctx.scheduler.runAfter(0, "payments:charge", { orderId });
});

runAfter(0, ...) is the idiomatic "do this right after I commit, but not in my transaction" hand-off — literally so: the job reaches the scheduler after the COMMIT, in the order the handler scheduled them.

If the job can't reach the scheduler

Everything between the COMMIT and the scheduler's acknowledgement happens outside the transaction, so it can fail on its own: the SchedulerDO unreachable, the shard evicted mid-hand-off. The write is durable by then and the job is not, and rolling back is no longer on the table.

So the shard takes durable custody of each deferred job as the handler schedules it — a row written inside the same transaction as the writes, released once the scheduler has the job. A hand-off that fails still raises (a caller error like a duplicate job id has to reach you), but what it can no longer do is lose the job: the entry survives, and the shard re-offers it on a backoff ladder until the scheduler accepts it. The retry reuses the id you were handed, so a job that did land is recognised rather than scheduled twice, and the cancel(id) you stored on a row keeps working throughout.

A job the scheduler refuses eight times over is parked: it stops being retried, is logged at error naming its id and target, and is kept for a week so you can see what was promised and never enqueued. That is the one case where a scheduled job is dropped without the mutation being rolled back — and it is reported rather than silent.

Inspecting and cancelling

ctx.scheduler also exposes list(), get(id), and cancel(id) for managing pending jobs. For example, cancel a scheduled reminder when the user completes the action first:

const { cancelled } = await ctx.scheduler.cancel(reminderId);

You can also read pending jobs from a query via the _scheduled_functions system table (ctx.db.system), which is read-only and eventually consistent.

Recurring jobs (Cron Triggers)

For work on a fixed cadence (nightly cleanups, hourly digests) declare Cron Triggers. They run on the platform's cron schedule, and the worker's scheduled() handler dispatches the target straight to its shard — not through the SchedulerDO:

import { createCronTrigger } from "@lunora/scheduler";

import { internal } from "@/lunora/_generated/api";

export const triggers = [
    createCronTrigger({ schedule: "0 3 * * *", fn: internal.cleanup.cleanupOldMessages }),
    createCronTrigger({ schedule: "*/15 * * * *", fn: internal.presence.sweep }),
];

schedule is the cron expression and fn is a function reference from _generated/api, not a path string — omit either and createCronTrigger throws "createCronTrigger() requires schedule and fn". Each call returns { crons, wranglerJsonc, dispatcher }; paste wranglerJsonc under triggers.crons in your wrangler.jsonc.

The CLI's wrangler validator surfaces a missing or unreachable trigger as an error during dev, so a bad reference fails fast rather than silently never firing.

A cron fire gets none of the SchedulerDO's durability. The retry ladder, exponential backoff, and dead-letter park described above belong to ctx.scheduler jobs, which the DO owns. A cron trigger is dispatched once from the worker's scheduled() handler; a failure is logged as CRON_JOB_FAILED and the tick is gone until the next one. Make cron handlers idempotent, and re-derive missed work from your own data rather than assuming a redelivery.

How dispatch works

A scheduled job is stored in the SchedulerDO sorted by its fire time and dispatched on the DO's alarm. When it fires, the DO calls back into your worker and the runtime runs the target function on the same code path as an RPC: same context, same ctx.db. There's no separate "background worker" mental model to keep. A scheduled mutation is just your mutation, run later.

The one difference is the caller. A scheduled or cron dispatch is server-initiated: it carries no end-user identity, so ctx.auth.userId is null and ctx.ip is undefined. That is also what lets it reach internal functions. Pass whatever the job needs to know about a user in its args, and don't gate a scheduled function on ctx.auth.

See also