Overview

Cron and interval jobs for Vercube that run once per occurrence, however many replicas you scale to

The Scheduler module runs recurring work inside a Vercube app: cron expressions, fixed intervals, or both. It is built for apps that run as more than one process. Every replica computes the same schedule from the same code, and a shared store decides which replica runs each occurrence, so a nightly billing job runs once at 03:00 whether you have one pod or forty.

The rule behind it is to separate when something runs from who runs it:

ScopeWho runs an occurrenceNeeds a storeUse it for
cluster (default)exactly one replicayesbilling, reports, emails, anything with side effects
instanceevery replicanoclearing a local cache, refreshing in-memory config

A cluster job without a distributed store refuses to start in production instead of quietly running on every pod.

Installation

$ pnpm add @vercube/scheduler

Install the client of the store your replicas share. Both are optional dependencies:

$ pnpm add ioredis

MemoryStore needs nothing, and coordinates a single process only.

Quick Start

Register the plugin

The plugin binds the SchedulerManager, mounts the store and arms the timers once the app is up.

vercube.config.ts
import { defineConfig } from '@vercube/core';
import { defineSchedulerStore, SchedulerPlugin } from '@vercube/scheduler';
import { RedisStore } from '@vercube/scheduler/stores/RedisStore';

export default defineConfig({
  plugins: [
    [
      SchedulerPlugin,
      {
        store: defineSchedulerStore({
          store: RedisStore,
          initOptions: { url: process.env.REDIS_URL },
        }),
        defaults: { timezone: 'Europe/Warsaw' },
      },
    ],
  ],
});

Listing the plugin in vercube.config.ts, rather than calling app.addPlugin(), is also what adds the vercube scheduler commands to the CLI.

Declare jobs

@Cron() and @Every() turn a method into a job. The method receives a JobContext.

src/jobs/BillingJobs.ts
import { Inject } from '@vercube/di';
import { Cron, Every } from '@vercube/scheduler';
import { InvoiceService } from '../services/InvoiceService';
import type { SchedulerTypes } from '@vercube/scheduler';

export class BillingJobs {
  @Inject(InvoiceService)
  private gInvoices!: InvoiceService;

  // once per day across the whole cluster, on Warsaw wall clock time
  @Cron('0 3 * * *', { name: 'billing.close-day' })
  public async closeDay(ctx: SchedulerTypes.JobContext): Promise<void> {
    for (const batch of await this.gInvoices.openBatches()) {
      ctx.signal.throwIfAborted();
      await this.gInvoices.close(batch, { idempotencyKey: ctx.occurrenceId });
    }
  }

  // every replica clears its own cache
  @Every('5m', { scope: 'instance' })
  public flushLocalCache(): void {
    this.gInvoices.clearCache();
  }
}

A cluster job needs an explicit name. It is part of every lock key, and a name derived from the class could change between builds, since bundlers rename classes whose names collide.

Bind the class

Job classes are ordinary container services. Bind them where you bind your controllers.

src/container.ts
import { BillingJobs } from './jobs/BillingJobs';
import type { Container } from '@vercube/di';

export function setupContainer(container: Container): void {
  container.bind(BillingJobs);
}

Health checks

With health on, the Redis and Postgres stores add a readiness check named scheduler:store. Pass health: false to SchedulerPlugin to leave it out, or health: { critical: false } to report it without failing readiness. The memory store has nothing to check.

Where to go next

  • Jobs: decorators, every option, the job context, time zones and daylight saving time.
  • Clustering: how claims work, the stores, Kubernetes, graceful shutdown, external triggers and queue targets.
  • API: the plugin, SchedulerManager, the stores, the CLI and testing helpers.