Demo

Cron job

Quark ERP supports two cron styles: process-local @CronJob (in-process timer via the cron package) and distributed CronJobService (BullMQ + Redis + Postgres). Use BullMQ for shared / multi-instance schedules; use @CronJob for simple single-process ticks.

Two types

Pick the style that matches how the work must run.

  • Process cron (@CronJob) — in-process schedule started when the @Service is resolved; uses the Node cron package (timer in this process only)
  • BullMQ cron (CronJobService) — queue-backed schedule on Redis with metadata in Postgres (cron_jobs); retries, repeat limits, multi-instance safe

When to use which

  • BullMQ CronJobService — domain sweeps, reminders, reconciles, anything that must survive restart and stay correct with multiple app instances
  • Process @CronJob — lightweight housekeeping on a single process, or when you explicitly want an in-memory tick with no Redis
  • Do not use @quan-erp-plugins/cron-schedular-backend for new work — the same Redis APIs live in core as CronJobService

1. Process cron (@CronJob)

Mark a method on a DI-resolved @Service. When the class is resolved, the core starts a process-local CronJob from the expression. No DB row, no Redis queue, no retries — each process that loads the service ticks independently.

  • expression (required) — cron pattern, e.g. 0 * * * *
  • name (optional) — label for logs
  • Class must be @Service() (or otherwise DI-resolved) and registered on the module providers
TSX@CronJob
1import { CronJob, Module, Service } from "@quan-erp/shared-backend-core"; 2import metadata from "../../module.metadata.json" with { type: "json" }; 3 4@Service() 5export class LocalHousekeepingService { 6 @CronJob({ expression: "0 * * * *", name: "HourlyLocalCleanup" }) 7 async cleanup() { 8 // runs on this Node process only 9 } 10} 11 12@Module({ 13 name: metadata.name, 14 providers: [LocalHousekeepingService], 15 controllers: [], 16 entities: [], 17}) 18export class MyPluginModule {}

2. BullMQ cron (CronJobService)

Jobs are enqueued through BullMQ on Redis. Schedule metadata is persisted in Postgres (cron_jobs). On boot, active schedules reload into Redis. Handlers run when the BullMQ worker fires — re-attach listeners every boot because callbacks are in-memory only.

Inject CronJobService

Inject the builtin core service with ContainerRegistryManager.BUILTIN_PLUGIN.

TSXinject
1import { 2 ContainerRegistryManager, 3 CronJobService, 4 Inject, 5 Service, 6} from "@quan-erp/shared-backend-core"; 7 8@Service() 9export class MaturedSweepService { 10 @Inject(CronJobService, ContainerRegistryManager.BUILTIN_PLUGIN) 11 private cronJobService: CronJobService; 12}

Register a BullMQ job

Call register with a unique (pluginName, jobName) pair. Use metadata.name as pluginName. register upserts the DB row and (re)creates the Redis scheduler.

TSXregister
1import { 2 ContainerRegistryManager, 3 CronJobService, 4 Inject, 5 Service, 6} from "@quan-erp/shared-backend-core"; 7import metadata from "../../module.metadata.json" with { type: "json" }; 8 9@Service() 10export class MaturedSweepService { 11 @Inject(CronJobService, ContainerRegistryManager.BUILTIN_PLUGIN) 12 private cronJobService: CronJobService; 13 14 async registerJob() { 15 await this.cronJobService.register({ 16 cronExpression: "0 2 * * *", 17 pluginName: metadata.name, 18 jobName: "matured-daily-sweep", 19 status: "active", 20 startDate: new Date(), 21 data: { reason: "daily-sweep" }, 22 retry: { attempt: 3, delay: 5_000, type: "exponential" }, 23 callback: async (job) => { 24 await this.runSweep(job.data); 25 }, 26 }); 27 } 28 29 private async runSweep(data: unknown) { /* … */ } 30}

Re-attach listeners after restart

Schedules reload from Postgres into Redis on boot. Callbacks are not persisted — always re-register on @OnAllModuleLoaded (or pass callback again via register).

TSXlistener
1import { 2 ContainerRegistryManager, 3 CronJobService, 4 Inject, 5 OnAllModuleLoaded, 6 Service, 7} from "@quan-erp/shared-backend-core"; 8import metadata from "../../module.metadata.json" with { type: "json" }; 9 10@Service() 11export class MaturedSweepService { 12 @Inject(CronJobService, ContainerRegistryManager.BUILTIN_PLUGIN) 13 private cronJobService: CronJobService; 14 15 @OnAllModuleLoaded() 16 async onAllModuleLoaded() { 17 this.cronJobService.addEventListener( 18 metadata.name, 19 "matured-daily-sweep", 20 async (job) => { await this.runSweep(job.data); }, 21 ); 22 } 23 24 private async runSweep(data: unknown) { /* … */ } 25}

Stop / remove (BullMQ)

  • stop(pluginName, jobName) — pause scheduler + mark inactive
  • remove(pluginName, jobName) — delete Redis scheduler, DB row, and listeners
  • stopAllByPluginName / removeAllByPluginName — bulk helpers on uninstall

Query helpers

TSXquery
1import { 2 ContainerRegistryManager, 3 CronJobService, 4 Inject, 5 Service, 6} from "@quan-erp/shared-backend-core"; 7import metadata from "../../module.metadata.json" with { type: "json" }; 8 9@Service() 10export class MaturedSweepService { 11 @Inject(CronJobService, ContainerRegistryManager.BUILTIN_PLUGIN) 12 private cronJobService: CronJobService; 13 14 async listJobs() { 15 const job = await this.cronJobService.getByPluginNameWithJobName( 16 metadata.name, 17 "matured-daily-sweep", 18 ); 19 20 const jobs = await this.cronJobService.getByPluginName({ 21 pluginName: metadata.name, 22 skip: 0, 23 limit: 50, 24 status: "active", 25 }); 26 27 return { job, jobs }; 28 } 29}

CreateCronJobDTO

  • cronExpression (required) — e.g. 0 2 * * *
  • pluginName / jobName (required) — key is `${pluginName}/${jobName}`
  • status — active | inactive
  • data — payload on BullMQ job.data
  • startDate — earliest start (initial delay)
  • repeat.limit — max executions
  • retry.attempt / delay / type (fixed | exponential)
  • callback — optional in-memory listener at register time

Recommended BullMQ pattern

  1. 01

    Inject

    CronJobService with ContainerRegistryManager.BUILTIN_PLUGIN.

  2. 02

    Register on boot

    On @OnAllModuleLoaded, register({ pluginName: metadata.name, jobName, cronExpression, … }).

  3. 03

    Attach listener every boot

    callback on register and/or addEventListener — schedules survive restart; handlers do not.

  4. 04

    Cleanup

    On uninstall / disable, stop or remove to avoid orphan Redis schedulers.

TSXend-to-end
1import { 2 ContainerRegistryManager, 3 CronJobService, 4 Inject, 5 OnAllModuleLoaded, 6 Service, 7} from "@quan-erp/shared-backend-core"; 8import metadata from "../../module.metadata.json" with { type: "json" }; 9 10const JOB_NAME = "inventory-nightly-reconcile"; 11 12@Service() 13export class InventoryReconcileCronService { 14 @Inject(CronJobService, ContainerRegistryManager.BUILTIN_PLUGIN) 15 private cronJobService: CronJobService; 16 17 @OnAllModuleLoaded() 18 async onAllModuleLoaded() { 19 await this.cronJobService.register({ 20 cronExpression: "0 3 * * *", 21 pluginName: metadata.name, 22 jobName: JOB_NAME, 23 startDate: new Date(), 24 callback: async () => { await this.reconcile(); }, 25 }); 26 } 27 28 private async reconcile() { /* domain work */ } 29}