Skip to main content

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.log lines, not a standard cron job table.
  • Cron schedules are not visible to ops without grep.
  • Replacing setTimeout with NestJS's @Cron decorator 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.
  • scheduleNext private method.
  • intervalMs constructor read.
  • timer field.

Keep:

  • tick() — still the unit of work.
  • draining mutex 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: SchedulerRegistry exposes 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 ScheduleModule means crons don't fire — so tests stay deterministic. tick() remains the unit-test entry point.
  • One scheduler: Instead of three setTimeout loops 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.