Cron Schedules
Since 0.2.0, recurring work is driven by rows in your database, not
in-memory timers: a job_schedules row says "enqueue job X on this cron",
survives restarts, is safe across multiple instances, and can be edited at
runtime. (@nestjs/schedule timers die with the process and double-fire when
you scale out — that is the gap this closes.)
Opt in
Schedules are opt-in. Add the jobSchedules table for your dialect to
your Drizzle schema (generate the migration with drizzle-kit) and pass a
schedule store to the module — without it, nothing about 0.1 behavior changes:
import { JobsModule } from '@nest-native/jobs';
import { SqliteJobStore, SqliteScheduleStore, jobs, jobSchedules } from '@nest-native/jobs/sqlite';
JobsModule.forRoot({
drizzleInstanceToken: DRIZZLE,
store: new SqliteJobStore(),
scheduleStore: new SqliteScheduleStore(), // ← schedules on
});
PostgresScheduleStore (@nest-native/jobs/postgres) and
MysqlScheduleStore (@nest-native/jobs/mysql) work identically.
Define a schedule
JobSchedulesService is the injectable CRUD — deliberately no REST
controller and no admin UI; expose it from your own endpoints if you want
runtime editing over HTTP:
@Injectable()
export class ReportsSetup implements OnApplicationBootstrap {
constructor(private readonly schedules: JobSchedulesService<SqliteScheduleStore>) {}
onApplicationBootstrap() {
this.schedules.upsert({
name: 'nightly-report', // unique schedule identity (upsert key)
jobName: 'report.build', // the @JobHandler each occurrence runs
payload: { kind: 'daily' },
cron: '0 3 * * *', // croner syntax
timezone: 'America/Sao_Paulo', // IANA; omitted = UTC
uniqueKey: 'nightly-report', // optional overlap guard (see below)
});
}
}
upsert validates the cron expression (and timezone) at call time — invalid
schedules, including expressions that can never fire again (0 0 30 2 *),
throw InvalidScheduleError and never reach the table. Like enqueue,
upsert returns the store's native shape (synchronous on sqlite) and rides
the caller's @Transactional context.
Boot-time upserts (the pattern above) are safe on every restart: on an
existing schedule, an omitted enabled preserves the stored value — so an
ops setEnabled(name, false) kill switch survives redeploys — and the stored
next_run_at is preserved while cron/timezone are unchanged, so a
pending catch-up is not skipped and the rhythm is not perturbed. Changing the
cron (or timezone) re-arms from now.
Manage at runtime: get(name), list(), setEnabled(name, enabled),
remove(name).
How firing works
The claimer's tick drains due schedules before claiming jobs, so an
occurrence is claimable by the very tick that fired it. Firing is an atomic
compare-and-swap on next_run_at plus the occurrence insert in one store
transaction — with several instances polling, exactly one wins each
occurrence, on every dialect. TickReport.scheduled counts the wins.
Misfire policy (fixed): missed occurrences are skipped — at most one
catch-up. next_run_at always advances from now; a schedule that was down
for a week fires once, then resumes its normal rhythm. Re-enabling a
long-disabled schedule likewise re-arms from the next FUTURE occurrence (no
burst).
Overlap guard: give the schedule a uniqueKey and every occurrence
enqueues with it — while one occurrence is still pending/processing, the next
becomes a dedup no-op through the normal
uniqueKey contract. No pile-ups
behind a slow handler.
Failure isolation: the schedule row is the source of truth. An occurrence
exhausting its retries marks that job failed and never touches the
schedule — the next occurrence fires on time. A transient store error during
a claim (connection drop, lock timeout) leaves the schedule untouched — it is
simply retried on the next tick. Only a schedule whose cron can no longer be
evaluated (hand-edited row) is disabled, with the error recorded in its
last_error column instead of crashing the worker loop.
Timezones and DST are delegated to croner — the package's single runtime dependency. The default timezone is UTC, not server-local, so a schedule means the same thing on every instance.