Migrating poller services to @nestjs/schedule (@Cron)
Three services run on internal setTimeout-based loops:
PayrollDeductionsPollerService(P5-b)PayrollAutoLockService(P6.b)CourtOrderAutoSubmitService(P5-d.e)
Each owns intervalMs, a draining mutex, an onModuleInit
scheduler, and an onModuleDestroy cleanup. They work, but:
- Observability is per-service
Logger.loglines, not a standard cron job table. - Cron schedules are not visible to ops without grep.
- Replacing setTimeout with NestJS's
@Crondecorator drops ~20 LOC per service and uses a single observable scheduler.
Step 1 — install the dep
pnpm add @nestjs/schedule
The dep is already declared in package.json. The pnpm install
above pulls it into node_modules.
Step 2 — register the ScheduleModule
// apps/api/src/app/app.module.ts
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
// ...existing imports
ScheduleModule.forRoot(),
],
})
export class AppModule {}
Step 3 — decorate the existing tick() methods
Each poller already exposes a public tick() method. The migration
is a single decorator + dropping the manual setTimeout machinery.
PayrollDeductionsPollerService
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class PayrollDeductionsPollerService {
// ...constructor
// Replaces onModuleInit + scheduleNext + drain.
@Cron(CronExpression.EVERY_5_SECONDS)
async scheduledTick(): Promise<void> {
if (!this.config.PAYROLL_DEDUCTIONS_POLLER_ENABLED) return;
try {
await this.tick();
} catch (err) {
this.logger.error('tick failed', err as Error);
}
}
}
PayrollAutoLockService
@Cron(CronExpression.EVERY_HOUR)
async scheduledTick(): Promise<void> {
if (!this.config.PAYROLL_AUTO_LOCK_ENABLED) return;
try { await this.tick(); } catch (err) {
this.logger.error('tick failed', err as Error);
}
}
CourtOrderAutoSubmitService
@Cron(CronExpression.EVERY_15_MINUTES)
async scheduledTick(): Promise<void> {
if (!this.config.PAYROLL_COURT_AUTO_SUBMIT_ENABLED) return;
try { await this.tick(); } catch (err) {
this.logger.error('tick failed', err as Error);
}
}
Step 4 — drop the manual scheduling
Delete from each service:
onModuleInit— the decorator handles startup.onModuleDestroy— the decorator + Nest's lifecycle handles teardown.scheduleNextprivate method.intervalMsconstructor read.timerfield.
Keep:
tick()— still the unit of work.drainingmutex IF you want guaranteed serialisation across overruns (the @Cron decorator doesn't prevent overlapping ticks). Otherwise delete and rely on cron's promise-await behaviour.
Step 5 — verify
# Boot the api with each cron enabled
PAYROLL_DEDUCTIONS_POLLER_ENABLED=true \
PAYROLL_AUTO_LOCK_ENABLED=true \
PAYROLL_COURT_AUTO_SUBMIT_ENABLED=true \
pnpm dev:api
Look for [Scheduler] Crons log lines at startup listing each
registered cron + its expression.
Why this matters
- Observability:
SchedulerRegistryexposes every cron via a type-safe API. An admin route can list/inspect/pause crons without custom plumbing. - Testability: Spinning up a Nest test module without
ScheduleModulemeans crons don't fire — so tests stay deterministic.tick()remains the unit-test entry point. - One scheduler: Instead of three
setTimeoutloops competing for the event loop, one scheduler queues every cron.
Rollback
The original setTimeout machinery is in git. Revert the commit and
remove @nestjs/schedule from imports + package.json.