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
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.
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.
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).
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
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
- 01
Inject
CronJobService with ContainerRegistryManager.BUILTIN_PLUGIN.
- 02
Register on boot
On @OnAllModuleLoaded, register({ pluginName: metadata.name, jobName, cronExpression, … }).
- 03
Attach listener every boot
callback on register and/or addEventListener — schedules survive restart; handlers do not.
- 04
Cleanup
On uninstall / disable, stop or remove to avoid orphan Redis schedulers.
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}