convex-cron-jobs

convex-cron-jobs

热门

Schedules work in Convex: cron jobs in convex/crons.ts, one off scheduled functions with runAfter and runAt, batching large jobs, and cancelling or inspecting the queue. Use when something needs to run on a timer, later, or in the background, or when a cron is not firing.

405Star
32Fork
更新于 2026/9/28
请求的译文尚未完成,当前显示原始英文。
SKILL.md
只读
名称
convex-cron-jobs
描述

Schedules work in Convex: cron jobs in convex/crons.ts, one off scheduled functions with runAfter and runAt, batching large jobs, and cancelling or inspecting the queue. Use when something needs to run on a timer, later, or in the background, or when a cron is not firing.

Convex cron jobs and scheduling

Recurring work lives in convex/crons.ts. One off work is scheduled from inside a function with ctx.scheduler. Both must target internal.* functions, never api.*.

When to reach for this

  • Something needs to run every N minutes or at a fixed time of day
  • A mutation needs to kick off follow up work after it commits
  • A job touches more rows than one mutation should handle
  • A cron shows in the dashboard but never runs, or runs at the wrong hour
  • A pending job needs to be cancelled, debounced, or inspected

Deeper material lives in two reference files:

crons.ts skeleton

One file, one default export. crons.interval for "every N", crons.cron for calendar times. crons.daily, crons.hourly, and crons.weekly are deprecated helpers. Do not use them.

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

// Every hour
crons.interval(
  "expire sessions",
  { hours: 1 },
  internal.sessions.expireBatch,
  {},
);

// Every day at 09:00 UTC. Cron expressions are always UTC.
crons.cron("daily digest", "0 9 * * *", internal.digest.send, {});

export default crons;

Rules for every entry:

  • Names are unique within the file. The dashboard lists jobs by this name.
  • Import internal from ./_generated/api, even when the target is defined in crons.ts.
  • Args are static and must satisfy the target function's args validator.
  • Interval units are { seconds }, { minutes }, or { hours }.

Cron expression quick reference (minute hour day-of-month month day-of-week):

Expression Runs
*/15 * * * * every 15 minutes
0 * * * * every hour at :00
0 0 * * * daily at 00:00 UTC
0 8 * * 1 Mondays at 08:00 UTC
0 0 1 * * first of each month
0 9-17 * * 1-5 hourly, 09:00 to 17:00 UTC, weekdays

Targets are internal functions

Public functions expect a client, an auth identity, and untrusted input. Cron and scheduler calls have none of that. A public target skips the auth checks you wrote and exposes the job to anyone who can reach the deployment. Register targets with internalMutation, internalAction, or internalQuery.

One batched job

A mutation is one transaction with read and write limits. Deleting fifty thousand rows in a loop hits them. Take a fixed slice, reschedule yourself with runAfter(0, ...), and let the chain finish on its own.

// convex/sessions.ts
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

const BATCH = 100;

export const expireBatch = internalMutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    // Date.now() is fine in a mutation. Never call it in a query.
    const now = Date.now();
    const expired = await ctx.db
      .query("sessions")
      .withIndex("by_expiresAt", (q) => q.lt("expiresAt", now))
      .take(BATCH);

    await Promise.all(expired.map((s) => ctx.db.delete(s._id)));

    // A full batch means more may remain. Chain the next one.
    if (expired.length === BATCH) {
      await ctx.scheduler.runAfter(0, internal.sessions.expireBatch, {});
    }
    return null;
  },
});

This shape works because each delete removes the row from the index range. If the job updates rows without moving them out of the range, use a pagination cursor instead. See the reference file.

runAfter vs runAt

// Relative: 5 minutes from now
const jobId = await ctx.scheduler.runAfter(
  5 * 60 * 1000,
  internal.reminders.send,
  { taskId: args.taskId },
);

// Absolute: a timestamp you already store (ms since epoch or a Date)
await ctx.scheduler.runAt(trial.endsAt, internal.billing.endTrial, {
  userId: trial.userId,
});
Method Use for
runAfter(delayMs, fn, args) retries, follow ups, "in ten minutes"
runAt(timestamp, fn, args) trial ends, send dates, anything with a stored time

Both return an Id<"_scheduled_functions">. Store it on a document if you may need to cancel.

Two behaviors to remember:

  • Scheduling inside a mutation is transactional. If the mutation throws, nothing is scheduled. Scheduling inside an action happens right away, even if the action fails later.
  • Scheduled mutations run exactly once. Scheduled actions may fail without retry, so add retry logic to actions or use a retry component.

Seeing runs in the dashboard

  • Schedules, Cron Jobs tab: every entry from crons.ts, with last run and next run.
  • Schedules, Scheduled Functions tab: pending runAfter and runAt jobs.
  • Logs, filtered by function name: each execution, its duration, and any thrown error.
  • From the CLI: npx convex logs streams the same log lines.
  • From code: ctx.db.system.get(jobId) returns the job document with state.kind set to pending, inProgress, success, failed, or canceled.

When a cron is not firing

  1. The file is exactly convex/crons.ts and ends with export default crons.
  2. npx convex dev is running and the last push succeeded. Cron changes only apply on push.
  3. The target is internal.* and the args match its validator. A mismatch fails at push time.
  4. The expression is UTC. Convert your local hour before comparing.
  5. Check Logs for a thrown error. A job that throws every run looks like a job that never runs.

Common mistakes

Mistake Why it breaks Do instead
crons.daily(...) deprecated helper crons.cron("...", "0 0 * * *", ...)
Target is api.tasks.cleanup skips auth, publicly callable register as internalMutation, use internal.tasks.cleanup
.collect() then loop over thousands hits transaction limits take(BATCH) and reschedule
.withIndex("by_x").filter(...) filter scans the whole index put the range in withIndex
Date.now() in an internalQuery breaks caching and reactivity pass now as an arg from the caller
Cron at "0 9 * * *" for 9am Pacific runs at 9am UTC use UTC, or run hourly and check local hour
Missing await on runAfter job may not be scheduled always await ctx.scheduler.*
Two crons with the same name push fails unique names per file

Checklist

  • [ ] convex/crons.ts uses only crons.interval and crons.cron, ends with export default crons
  • [ ] Every cron and scheduler target is internal.*
  • [ ] Every target has args and returns validators
  • [ ] Jobs that touch many rows take a batch and reschedule with runAfter(0, ...)
  • [ ] Range conditions live in withIndex, not .filter
  • [ ] No Date.now() inside queries
  • [ ] Every ctx.scheduler.* call is awaited
  • [ ] Cron hours are written in UTC
  • [ ] Job ids are stored on documents when cancel or debounce is needed
  • [ ] Ran npx convex dev and saw the job listed under Schedules

Docs