Stack logo
Sync up on the latest from Convex.
James Cowling's avatar
James Cowling
4 months ago

Configure Cron Jobs at Runtime

icon of a gear and timer, representing scheduled cron jobs

Crons are periodic jobs that run on a given schedule. They’re often used to trigger business processes, e.g., send weekly summary emails to customers, or compute monthly leaderboard metrics, or perhaps to disable user accounts that have been inactive for a while. Built-in support for crons and scheduled functions means that Convex can be used to build your entire company, not just your frontend UI.

One limitation with built-in crons in Convex is that they need to be defined statically in a file called crons.ts. This is fine for jobs you know you need to run ahead of time, but makes it tricky to programmatically register cron jobs at runtime. Fortunately Convex is a platform, not just a set of tools; if a feature we want doesn’t exist, we can just built it ourselves!

This article describes the implementation of user space crons in Convex. I call these “user space” crons because they’re implemented on top of the Convex kernel, just like a regular library running on top of an operating system kernel. Check out the Convex helpers repo for a list of other handy user space libraries.

Crons Component

Update: With the launch of Convex Components, runtime crons are now available as a drop-in component in your Convex app. This article still serves as a good reference for how this component is designed internally.

Cronvex

I’ve built a demo of user space crons called Cronvex, which can be used to register periodic jobs that call third party http endpoints. Cronvex is like EasyCron or FastCron but entirely free and open source. Feel free to use the hosted version for triggering crons of your own, or fork the repo and build your own app.

The Cronvex WebsiteThe Cronvex Website

If all you want is a free service to trigger scheduled jobs then head over to Cronvex and you’re good to go. If you’re curious about how user space crons are implemented in Convex, and how you can use them in your own project, read on.

User space crons

One of the core principles of Convex is that if a feature doesn’t exist already you should be able to build it. This applies for cron jobs that you can register and manage at runtime. So let’s get building. You can follow along with cronlib.ts from Cronvex.

State

We’ll start with the state required to model user space crons, which is usually the best place to start when designing a workflow like this.

crons: defineTable({
  name: v.optional(v.string()), // optional cron name
  functionName: v.string(), // fully qualified function name
  args: v.any(), // args as an object
  schedule: v.union(
    v.object({
      kind: v.literal("interval"),
      ms: v.float64(), // milliseconds
    }),
    v.object({
      kind: v.literal("cron"),
      cronspec: v.string(), // "* * * * *"
    })
  ),
  schedulerJobId: v.optional(v.id("_scheduled_functions")),
  executionJobId: v.optional(v.id("_scheduled_functions")),
}).index("name", ["name"]),

We need a functionName reference to the periodic function we want to execute, which in this case is a fully qualified string like internal.cronvex.callWebhook, along with any args to the function. Each cron can also have an optional unique name so we can easily look it up.

We also need to store the schedule to run the periodic job. There are two different types of schedules: an interval which represents “run this every X milliseconds”; and a cronspec, which is a special string describing a schedule like “run every Monday morning at 9am”. This format originates from the Unix cron command and is documented in the cron function in cronlib.ts.

The “Add a cron” button in Cronvex includes some convenient helpers to avoid having to memorize the cron spec syntax but they all convert down to an interval or cronspec.

Cronvex.com scheduling formCronvex.com scheduling form

Finally we need to keep track of two scheduled jobs that we’ll use to implement user space crons. Maintaining a record of these will allow us to delete crons and to perform some runtime checks. More on these in the next section.

Workflow

The workflow for executing crons is relatively simple, with one caveat. The basic algorithm is just to recursively reschedule a function on the desired schedule. The caveat is that we need this workflow to be reliable. If a function fails half-way though its execution we wouldn’t want that to mean the cron never runs again.

Fortunately Convex mutations are idempotent, reliable, and retried until success. This is distinct from Convex actions which could fail when trying to talk to the outside world and are not always safe to retry. A really useful pattern in Convex is to use reliable mutations to build your workflows and then have these mutations schedule actions via await ctx.scheduler.runAfter(0, ... to do any work that may fail, without interfering with the workflow.

User space crons workflow leveraging reliable mutations and a potentially-unreliable cron job.User space crons workflow leveraging reliable mutations and a potentially-unreliable cron job.

The basic workflow is shown above. The scheduleCron mutation writes initial state to the crons table and schedules the rescheduler mutation to run at the next desired execution time. This scheduled mutation is recorded as schedulerJobId in the crons table so we can cancel the cron job if needed.

Whenever the rescheduler mutation runs it immediately schedules a separate function to execute the actual user cron job and keeps track of it via executionJobId, then the rescheduler reschedules itself for the next desired interval. Even though the cron job executes immediately, it runs in a separate context from rescheduler, so whether or not it fails or takes a long time will not impact the workflow.

At steady-state we have the rescheduler continually rescheduling itself on the desired cron schedule and triggering an asynchronous job to execute the user’s cron job if it’s not already running.

Statically defined cron jobs

The library we just built handles registration of crons at runtime but what if you want it to handle build-time cron jobs like those that are included with Convex? This is easy to achieve via an init.ts script that checks if the cron already exists and registers it otherwise.

// This is an idempotent bootstrap script that can be used to set up crons
// similarly to the old crons.ts file. It needs to be run manually, e.g., by
// running `convex dev --run init`.
export default internalMutation({
  handler: async (ctx) => {
    if ((await getByName(ctx, "exampleDailyCron")) == null) {
      await cronWithName(
        ctx,
        "exampleDailyCron",
        "0 0 * * *",
        internal.init.exampleCron,
        {
          message: "unnecessary daily log message!",
        }
      );
    }
  },
});

You’ll also need to make sure this init.ts bootstrap script runs on every startup via a predev package.json hook. Don’t forget to make sure this runs on every deploy too, e.g., npx convex deploy --cmd='npm run build' && npx convex run init. Crons initialized in here will run forever, so if you want to remove one you’ll have to delete it via cronlib.delByName.

In hindsight maybe we could have left cron jobs out of the core of Convex entirely and just provided a helper library like this. Lesson learned for Convex 2.0.

The power is yours

In this article we’ve learned how to implement crons that can be registered at runtime. More importantly however, we’ve learned that the core primitives in Convex provide a powerful “operating system” for building functionality in user space. Don’t just think of Convex as your database, think of it as your entire backend, and yes you should be able to program your backend.

Along the way we’ve also learned a few other things:

  1. Most protocols are best modeled as state machines where you should reason about the state in an application before reasoning about the transitions between this state.
  2. You should try to use mutations (and queries) as much as possible for the core logic in your Convex applications and then call out to actions via scheduling for unreliable functionality.
  3. Less is usually more when it comes to designing the API for a platform, provided that API is expressive enough to building any required functionality on top of it. If there’s some missing core functionality in Convex let us know!

and also that there’s a free crons service at cronvex.com. Feel free to use it or fork it!

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started