API
SchedulerPlugin
Binds the SchedulerManager, mounts the store and arms the timers once the container is initialized.
app.addPlugin(SchedulerPlugin, {
store: defineSchedulerStore({
store: RedisStore,
initOptions: { url: process.env.REDIS_URL },
}),
defaults: { timezone: 'Europe/Warsaw', lockTtl: '1m' },
handleSignals: true,
});
| Option | Default | Description |
|---|---|---|
store | none | Store holding the claims of cluster jobs. Wrap it in defineSchedulerStore() to have its initOptions checked. |
defaults | {} | Job options applied to every job that does not set them, plus timezone. |
autoStart | true | Arm the timers once the container is initialized. app.listen() waits for that start, so a scheduler that cannot start (a cluster job without a store) fails the boot before the port is bound. A mounted store that cannot connect is logged and does not stop the boot. false registers jobs without running them, for web-only pods. |
allowLocalCluster | false | Allow cluster jobs on a store that is not distributed in production. |
instanceId | HOSTNAME-pid-random | Id of this replica. |
shutdownTimeout | '25s' | How long a signal-triggered stop waits for runs in flight before aborting them. |
handleSignals | false | Stop on SIGTERM and SIGINT, or on the signals listed. |
http | none | { path?: string; secret: string } mounts the HTTP trigger. path defaults to /_scheduler. |
maxEvents | 50 | How many run events are kept for inspection. |
clock | system clock | Source of time and timers, for tests. |
Registering the plugin twice is harmless: the manager is bound once, settings are merged, and a mounted store is kept.
SchedulerManager
add() and remove()
scheduler.add({
name: 'tenant.42.sync',
schedule: { every: '15m' },
handler: (ctx) => sync(42, ctx.signal),
scope: 'cluster',
});
scheduler.remove('tenant.42.sync');
schedule is { cron: string; timezone?: string } or { every: Duration; offset?: Duration }. Every other field is a job option. An invalid schedule, a taken name or a bad option throws a SchedulerError.
trigger()
Runs a job now, through the same pipeline as its timer.
const result = await scheduler.trigger('billing.close-day');
// { name, occurrenceId, scheduledAt, outcome, attempts, durationMs, error? }
| Option | Description |
|---|---|
scheduledAt | Occurrence to claim. Without it, the run gets an occurrence of its own, name@manual:<epoch ms>, which never collides with a timer. |
source | Reported as ctx.trigger, manual by default. |
force | Skip the occurrence claim. Overlap still applies. |
outcome is one of completed, failed, aborted, enqueued, skipped-claimed, skipped-overlap or skipped-paused.
start(), stop() and close()
start() arms the timers and runs the startup catch-up. stop({ timeout }) disarms them, waits for runs in flight, aborts what is left at the timeout and releases overlap locks. close() stops and closes the store. All three are safe to call more than once.
pause() and resume()
Stop and restart the timer of one job on this replica. Other replicas keep running it. A paused job is skipped by trigger() too.
list(), nextRuns() and currentOccurrence()
// every job: schedule, scope, target, next run, last run on this replica
scheduler.list();
// the next three occurrences
scheduler.nextRuns('billing.close-day', 3);
// the latest occurrence at or before now
scheduler.currentOccurrence('billing.close-day');
getStates()
Reads what the store remembers of the last run of each job, which covers runs done by other replicas.
const states = await scheduler.getStates(['billing.close-day']);
// { 'billing.close-day': { lastScheduledAt, lastStartedAt, lastFinishedAt,
// lastOutcome, lastInstance, lastError } }
subscribe()
const unsubscribe = scheduler.subscribe((event) => {
if (event.outcome === 'failed') {
alerts.notify(
`${event.name} failed on ${event.instanceId}: ${event.error?.message}`,
);
}
});
Every occurrence this replica handles produces one event, skips and missed included.
inspect() and drain()
inspect() returns the started flag, the replica id, the store's status, every job and the recent events. Devtools reads it. drain() waits for every run in flight, including the ones timers started in the background.
Writing your own store
Extend SchedulerStore and implement six methods. claim, extend and release must be atomic, and extend and release must only act while the owner still holds the key.
import { SchedulerStore } from '@vercube/scheduler';
import type { SchedulerTypes } from '@vercube/scheduler';
export class EtcdStore extends SchedulerStore<{ endpoints: string[] }> {
public readonly name = 'etcd';
public override get capabilities(): SchedulerTypes.StoreCapabilities {
return { distributed: true };
}
public async initialize(options: { endpoints: string[] }): Promise<void> {}
public async claim(key: string, owner: string, ttlMs: number): Promise<boolean> {}
public async extend(
key: string,
owner: string,
ttlMs: number,
): Promise<boolean> {}
public async release(key: string, owner: string): Promise<boolean> {}
public async getState(job: string): Promise<SchedulerTypes.JobState | null> {}
public async setState(
job: string,
state: SchedulerTypes.JobState,
): Promise<void> {}
public async close(): Promise<void> {}
}
Testing
ManualClock from @vercube/scheduler/testing replaces real time. Advancing it fires due timers in order and waits for the runs they start, so a test never sleeps.
import { SchedulerPlugin } from '@vercube/scheduler';
import { MemoryStore } from '@vercube/scheduler/stores/MemoryStore';
import { ManualClock } from '@vercube/scheduler/testing';
const clock = new ManualClock('2026-01-01T02:59:00Z');
app.addPlugin(SchedulerPlugin, { clock, store: { store: MemoryStore } });
await clock.advance('1m');
expect(invoices.closedDays).toEqual(['2026-01-01']);
| Method | Description |
|---|---|
advance(duration) | Moves time forward, firing due timers and waiting for their runs. |
set(date) | Jumps without firing anything, like a laptop waking up. The next advance() fires overdue timers late. |
sleep(duration) | Waits on the manual clock, for handlers simulating long work. |
pending() | When the pending timers are due. |
skewed(offset) | A view of the clock that is ahead or behind, sharing its timers, to simulate a replica with a skewed clock. |
To simulate several replicas, give several managers the same MemoryStore instance: mount({ store: sharedStore }) accepts an instance as well as a class.
With @vercube/testing, withManualClock() wires a clock and a MemoryStore into createTestApp(). See Testing extensions.
import { withManualClock } from '@vercube/scheduler/testing';
const time = withManualClock('2026-01-01T02:59:00Z');
const app = await createTestApp({ providers: [BillingJobs], extensions: [time] });
await time.clock.advance('1m');
SchedulerError
Every error the module throws.
| Field | Description |
|---|---|
operation | What failed: register, configure, start, claim, enqueue, or the reason a run was aborted: timeout, lock-lost, shutdown. |
cause | The underlying error, when there is one. |
metadata | Non-sensitive context, such as the job names involved. |
retryable | Whether trying again could succeed. |