Overview
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:
| Scope | Who runs an occurrence | Needs a store | Use it for |
|---|---|---|---|
cluster (default) | exactly one replica | yes | billing, reports, emails, anything with side effects |
instance | every replica | no | clearing 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
$ npm install @vercube/scheduler
$ bun install @vercube/scheduler
Install the client of the store your replicas share. Both are optional dependencies:
$ pnpm add ioredis
$ pnpm add pg
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.
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.
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.
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.