UNPKG

@convex-dev/crons

Version:

Convex component for scheduling periodic jobs.

226 lines 8.24 kB
// Implementation of crons in user space. // // See ../client/index.ts for the public API. import { v } from "convex/values"; import { mutation, query, internalMutation, } from "./_generated/server.js"; import { internal } from "./_generated/api.js"; import parser from "cron-parser"; import schema from "./schema.js"; const scheduleValidator = schema.tables.crons.validator.fields.schedule; const cronInfoValidator = v.object({ id: v.id("crons"), name: v.optional(v.string()), functionHandle: v.string(), args: v.record(v.string(), v.any()), schedule: scheduleValidator, }); /** * Schedule a mutation or action to run on a cron schedule or interval. * * @param name - Optional unique name for the job. Will throw if a name is * provided and a job with the same name already exists. * @param schedule - Either a cron specification string or an interval in * milliseconds. For intervals, ms must be >= 1000. * @param functionHandle - A {@link FunctionHandle} string for the function to * schedule. * @param args - The arguments to the function. * @returns The ID of the scheduled job. */ export const register = mutation({ args: { name: v.optional(v.string()), schedule: scheduleValidator, functionHandle: v.string(), args: v.record(v.string(), v.any()), }, returns: v.id("crons"), handler: async (ctx, { name, schedule, functionHandle, args }) => { if (name && (await ctx.db .query("crons") .withIndex("name", (q) => q.eq("name", name)) .unique())) { throw new Error(`Cron with name "${name}" already exists`); } validateSchedule(schedule); const id = await ctx.db.insert("crons", { functionHandle, args, name, schedule, }); console.log(`Scheduling cron "${name}" (${id}) on schedule ${JSON.stringify(schedule)}`); await scheduleNextRun(ctx, id, new Date(), schedule); return id; }, }); function validateSchedule(schedule) { if (schedule.kind === "interval" && schedule.ms < 1000) { throw new Error("Interval must be >= 1000ms"); } if (schedule.kind === "cron") { try { parser.parseExpression(schedule.cronspec, { tz: schedule.tz }); } catch { throw new Error(`Invalid cronspec: "${schedule.cronspec}"`); } } } async function scheduleNextRun(ctx, id, lastScheduled, schedule) { const nextRun = calculateNextRun(lastScheduled, schedule); const schedulerJobId = await ctx.scheduler.runAt(nextRun, internal.public.rescheduler, { id }); await ctx.db.patch(id, { schedulerJobId }); } function calculateNextRun(lastScheduled, schedule) { if (schedule.kind === "interval") { return new Date(lastScheduled.getTime() + schedule.ms); } else { const cron = parser.parseExpression(schedule.cronspec, { currentDate: lastScheduled, tz: schedule.tz, }); return cron.next().toDate(); } } /** * List all user space cron jobs. * * @returns List of `cron` table rows. */ export const list = query({ args: {}, returns: v.array(cronInfoValidator), handler: async (ctx) => { const crons = await ctx.db.query("crons").collect(); return crons.map((cron) => ({ id: cron._id, ...(cron.name && { name: cron.name }), functionHandle: cron.functionHandle, args: cron.args, schedule: cron.schedule, })); }, }); /** * Get an existing cron job by id or name. * * @param identifier - Either the ID or name of the cron job. * @returns Cron job document or null if not found. */ export const get = query({ args: { identifier: v.union(v.object({ id: v.id("crons") }), v.object({ name: v.string() })), }, returns: v.union(cronInfoValidator, v.null()), handler: async (ctx, { identifier }) => { const cron = "id" in identifier ? await ctx.db.get(identifier.id) : await ctx.db .query("crons") .withIndex("name", (q) => q.eq("name", identifier.name)) .unique(); if (!cron) return null; return { id: cron._id, ...(cron.name && { name: cron.name }), functionHandle: cron.functionHandle, args: cron.args, schedule: cron.schedule, }; }, }); /** * Delete and deschedule a cron job by id or name. * * @param identifier - Either the ID or name of the cron job. */ export const del = mutation({ args: { identifier: v.union(v.object({ id: v.id("crons") }), v.object({ name: v.string() })), }, returns: v.null(), handler: async (ctx, { identifier }) => { let cron; if ("id" in identifier) { cron = await ctx.db.get(identifier.id); if (!cron) { throw new Error(`Cron ${identifier.id} not found`); } } else { cron = await ctx.db .query("crons") .withIndex("name", (q) => q.eq("name", identifier.name)) .unique(); if (!cron) { throw new Error(`Cron "${identifier.name}" not found`); } } if (!cron.schedulerJobId) { throw new Error(`Cron ${cron._id} not scheduled`); } console.log(`Canceling scheduler job ${cron.schedulerJobId}`); await ctx.scheduler.cancel(cron.schedulerJobId); if (cron.executionJobId) { console.log(`Canceling execution job ${cron.executionJobId}`); await ctx.scheduler.cancel(cron.executionJobId); } console.log(`Deleting cron ${cron._id}`); await ctx.db.delete(cron._id); }, }); // Continue rescheduling a cron job. // // This is the main worker function that does the scheduling but also schedules // the target function so that it runs in a different context. As a result this // function probably *shouldn't* fail since it isn't doing much, but under heavy // OCC contention it's possible it may eventually fail. In this case the cron // will be lost and we'll need a janitor job to recover it. export const rescheduler = internalMutation({ args: { id: v.id("crons"), }, returns: v.null(), handler: async (ctx, { id }) => { // Cron job is the logical concept we're rescheduling repeatedly. const cronJob = await ctx.db.get(id); if (!cronJob) { throw Error(`Cron ${id} not found`); } if (!cronJob.schedulerJobId) { throw Error(`Cron ${id} not scheduled`); } // Scheduler job is the job that's running right now, that we use to trigger // repeated executions. const schedulerJob = await ctx.db.system.get(cronJob.schedulerJobId); if (!schedulerJob) { throw Error(`Scheduler job ${cronJob.schedulerJobId} not found`); } if (schedulerJob.state.kind !== "pending" && schedulerJob.state.kind !== "inProgress") { throw Error(`We are running in job ${schedulerJob._id} but state is ${schedulerJob.state.kind}`); } // Execution job is the previous job used to actually do the work of the cron. let stillRunning = false; if (cronJob.executionJobId) { const executionJob = await ctx.db.system.get(cronJob.executionJobId); if (executionJob && (executionJob.state.kind === "pending" || executionJob.state.kind === "inProgress")) { stillRunning = true; } } if (stillRunning) { console.log(`Cron ${cronJob._id} still running, skipping this run.`); } else { console.log(`Running cron ${cronJob._id}.`); await ctx.scheduler.runAfter(0, cronJob.functionHandle, cronJob.args); } await scheduleNextRun(ctx, id, new Date(schedulerJob.scheduledTime), cronJob.schedule); }, }); //# sourceMappingURL=public.js.map