@convex-dev/crons
Version:
Convex component for scheduling periodic jobs.
226 lines • 8.24 kB
JavaScript
// 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