@modern-admin/queue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cron/cron-task.decorator.d.ts +30 -0
- package/dist/cron/cron-task.decorator.d.ts.map +1 -0
- package/dist/cron/cron-task.decorator.js +17 -0
- package/dist/cron/cron-task.decorator.js.map +1 -0
- package/dist/cron/cron.constants.d.ts +5 -0
- package/dist/cron/cron.constants.d.ts.map +1 -0
- package/dist/cron/cron.constants.js +5 -0
- package/dist/cron/cron.constants.js.map +1 -0
- package/dist/cron/cron.module.d.ts +19 -0
- package/dist/cron/cron.module.d.ts.map +1 -0
- package/dist/cron/cron.module.js +43 -0
- package/dist/cron/cron.module.js.map +1 -0
- package/dist/cron/cron.processor.d.ts +13 -0
- package/dist/cron/cron.processor.d.ts.map +1 -0
- package/dist/cron/cron.processor.js +83 -0
- package/dist/cron/cron.processor.js.map +1 -0
- package/dist/cron/cron.service.d.ts +38 -0
- package/dist/cron/cron.service.d.ts.map +1 -0
- package/dist/cron/cron.service.js +175 -0
- package/dist/cron/cron.service.js.map +1 -0
- package/dist/cron/cron.types.d.ts +10 -0
- package/dist/cron/cron.types.d.ts.map +1 -0
- package/dist/cron/cron.types.js +2 -0
- package/dist/cron/cron.types.js.map +1 -0
- package/dist/cron/index.d.ts +7 -0
- package/dist/cron/index.d.ts.map +1 -0
- package/dist/cron/index.js +6 -0
- package/dist/cron/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/queue.module.d.ts +49 -0
- package/dist/queue.module.d.ts.map +1 -0
- package/dist/queue.module.js +93 -0
- package/dist/queue.module.js.map +1 -0
- package/dist/queue.types.d.ts +20 -0
- package/dist/queue.types.d.ts.map +1 -0
- package/dist/queue.types.js +2 -0
- package/dist/queue.types.js.map +1 -0
- package/package.json +54 -0
- package/src/cron/cron-task.decorator.ts +34 -0
- package/src/cron/cron.constants.ts +4 -0
- package/src/cron/cron.module.ts +33 -0
- package/src/cron/cron.processor.ts +86 -0
- package/src/cron/cron.service.ts +178 -0
- package/src/cron/cron.types.ts +13 -0
- package/src/cron/index.ts +11 -0
- package/src/index.ts +19 -0
- package/src/queue.module.ts +94 -0
- package/src/queue.types.ts +16 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var QueueModule_1;
|
|
8
|
+
import { Module } from '@nestjs/common';
|
|
9
|
+
import { BullModule } from '@nestjs/bullmq';
|
|
10
|
+
/**
|
|
11
|
+
* BullMQ integration module for modern-admin NestJS applications.
|
|
12
|
+
*
|
|
13
|
+
* 1. Call `QueueModule.forRoot()` once in the root AppModule to configure the
|
|
14
|
+
* Redis connection globally.
|
|
15
|
+
* 2. Call `QueueModule.register({ queues: [...] })` in any feature module to
|
|
16
|
+
* register named queues and make their injection tokens available.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // AppModule
|
|
20
|
+
* imports: [
|
|
21
|
+
* QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
|
|
22
|
+
* QueueModule.register({ queues: ['emails', 'exports'] }),
|
|
23
|
+
* CronModule,
|
|
24
|
+
* ]
|
|
25
|
+
*/
|
|
26
|
+
let QueueModule = QueueModule_1 = class QueueModule {
|
|
27
|
+
/**
|
|
28
|
+
* Configure the BullMQ Redis connection for the whole application.
|
|
29
|
+
* Must be imported once at the root level before any `register()` call.
|
|
30
|
+
*/
|
|
31
|
+
static forRoot(options) {
|
|
32
|
+
return {
|
|
33
|
+
module: QueueModule_1,
|
|
34
|
+
global: true,
|
|
35
|
+
imports: [
|
|
36
|
+
BullModule.forRoot({
|
|
37
|
+
connection: options.connection,
|
|
38
|
+
defaultJobOptions: options.defaultJobOptions,
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Async variant of `forRoot` — useful when the Redis URL comes from a
|
|
45
|
+
* config service or environment variable loaded at runtime.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* QueueModule.forRootAsync({
|
|
49
|
+
* imports: [ConfigModule],
|
|
50
|
+
* inject: [ConfigService],
|
|
51
|
+
* useFactory: (cfg: ConfigService) => ({
|
|
52
|
+
* connection: cfg.get('REDIS_URL'),
|
|
53
|
+
* }),
|
|
54
|
+
* })
|
|
55
|
+
*/
|
|
56
|
+
static forRootAsync(opts) {
|
|
57
|
+
return {
|
|
58
|
+
module: QueueModule_1,
|
|
59
|
+
global: true,
|
|
60
|
+
imports: [
|
|
61
|
+
BullModule.forRootAsync({
|
|
62
|
+
imports: opts.imports,
|
|
63
|
+
inject: opts.inject,
|
|
64
|
+
useFactory: async (...args) => {
|
|
65
|
+
const resolved = await opts.useFactory(...args);
|
|
66
|
+
return {
|
|
67
|
+
connection: resolved.connection,
|
|
68
|
+
defaultJobOptions: resolved.defaultJobOptions,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Register named queues (and optional flow producers) in the current module.
|
|
77
|
+
* Exports the BullMQ tokens so they can be injected via `@InjectQueue(name)`.
|
|
78
|
+
*/
|
|
79
|
+
static register(options) {
|
|
80
|
+
const queueModules = options.queues.map((name) => BullModule.registerQueue({ name }));
|
|
81
|
+
const flowModules = (options.flows ?? []).map((name) => BullModule.registerFlowProducer({ name }));
|
|
82
|
+
return {
|
|
83
|
+
module: QueueModule_1,
|
|
84
|
+
imports: [...queueModules, ...flowModules],
|
|
85
|
+
exports: [...queueModules, ...flowModules],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
QueueModule = QueueModule_1 = __decorate([
|
|
90
|
+
Module({})
|
|
91
|
+
], QueueModule);
|
|
92
|
+
export { QueueModule };
|
|
93
|
+
//# sourceMappingURL=queue.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.module.js","sourceRoot":"","sources":["../src/queue.module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAsB,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3C;;;;;;;;;;;;;;;GAeG;AAEI,IAAM,WAAW,mBAAjB,MAAM,WAAW;IACtB;;;OAGG;IACH,MAAM,CAAC,OAAO,CAAC,OAAyB;QACtC,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;gBACP,UAAU,CAAC,OAAO,CAAC;oBACjB,UAAU,EAAE,OAAO,CAAC,UAAoB;oBACxC,iBAAiB,EAAE,OAAO,CAAC,iBAAiB;iBAC7C,CAAC;aACH;SACF,CAAA;IACH,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,YAAY,CAAC,IAInB;QACC,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;gBACP,UAAU,CAAC,YAAY,CAAC;oBACtB,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,MAAM,EAAE,IAAI,CAAC,MAAiB;oBAC9B,UAAU,EAAE,KAAK,EAAE,GAAG,IAAe,EAAE,EAAE;wBACvC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC,CAAA;wBAC/C,OAAO;4BACL,UAAU,EAAE,QAAQ,CAAC,UAAoB;4BACzC,iBAAiB,EAAE,QAAQ,CAAC,iBAAiB;yBAC9C,CAAA;oBACH,CAAC;iBACF,CAAC;aACH;SACF,CAAA;IACH,CAAC;IAED;;;OAGG;IACH,MAAM,CAAC,QAAQ,CAAC,OAA2B;QACzC,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAC/C,UAAU,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,CAAC,CACnC,CAAA;QACD,MAAM,WAAW,GAAG,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACrD,UAAU,CAAC,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAC1C,CAAA;QACD,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,OAAO,EAAE,CAAC,GAAG,YAAY,EAAE,GAAG,WAAW,CAAC;YAC1C,OAAO,EAAE,CAAC,GAAG,YAAY,EAAE,GAAG,WAAW,CAAC;SAC3C,CAAA;IACH,CAAC;CACF,CAAA;AAxEY,WAAW;IADvB,MAAM,CAAC,EAAE,CAAC;GACE,WAAW,CAwEvB"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DefaultJobOptions } from 'bullmq';
|
|
2
|
+
export interface QueueModuleOptions {
|
|
3
|
+
queues: string[];
|
|
4
|
+
flows?: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface QueueRootOptions {
|
|
7
|
+
/**
|
|
8
|
+
* ioredis-compatible connection. Accepts a connection string
|
|
9
|
+
* (`redis://...`) or a plain options object.
|
|
10
|
+
*/
|
|
11
|
+
connection: {
|
|
12
|
+
host?: string;
|
|
13
|
+
port?: number;
|
|
14
|
+
password?: string;
|
|
15
|
+
db?: number;
|
|
16
|
+
} | string;
|
|
17
|
+
/** Default job options applied to every queue in this process. */
|
|
18
|
+
defaultJobOptions?: DefaultJobOptions;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=queue.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.types.d.ts","sourceRoot":"","sources":["../src/queue.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAA;AAE/C,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,UAAU,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAA;IACrF,kEAAkE;IAClE,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACtC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.types.js","sourceRoot":"","sources":["../src/queue.types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modern-admin/queue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BullMQ-based queue + cron module for Modern Admin (NestJS).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/modern-admin/modern-admin.git",
|
|
10
|
+
"directory": "packages/queue"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org",
|
|
26
|
+
"access": "public",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@modern-admin/tsconfig": "0.1.0",
|
|
38
|
+
"@nestjs/bullmq": "^11.0.4",
|
|
39
|
+
"@nestjs/common": "^11.1.19",
|
|
40
|
+
"@nestjs/core": "^11.1.19",
|
|
41
|
+
"@nestjs/testing": "^11.1.19",
|
|
42
|
+
"@types/bun": "^1.3.13",
|
|
43
|
+
"bullmq": "^5.77.2",
|
|
44
|
+
"reflect-metadata": "^0.2.2",
|
|
45
|
+
"rxjs": "^7.8.2",
|
|
46
|
+
"typescript": "^6.0.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@nestjs/bullmq": "^11.0.4",
|
|
50
|
+
"@nestjs/common": "^11.0.0",
|
|
51
|
+
"@nestjs/core": "^11.0.0",
|
|
52
|
+
"bullmq": "^5.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common'
|
|
2
|
+
import type { JobsOptions } from 'bullmq'
|
|
3
|
+
|
|
4
|
+
export const CRON_TASK_META = 'MODERN_ADMIN_CRON_TASK_META'
|
|
5
|
+
|
|
6
|
+
export interface CronTaskOptions {
|
|
7
|
+
/** Unique task name — used as the BullMQ job scheduler key. */
|
|
8
|
+
name: string
|
|
9
|
+
/** Cron expression, e.g. `"0 8 * * *"` for every day at 08:00 UTC. */
|
|
10
|
+
cron: string
|
|
11
|
+
/**
|
|
12
|
+
* When `true`, a distributed Redis lock is acquired before the handler runs.
|
|
13
|
+
* If the previous execution is still active, the new invocation is skipped.
|
|
14
|
+
*/
|
|
15
|
+
skipIfRunning?: boolean
|
|
16
|
+
/** Extra BullMQ job options (excluding `repeat` which is controlled by `cron`). */
|
|
17
|
+
opts?: Omit<JobsOptions, 'repeat'>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Marks a service method as a BullMQ-backed cron task.
|
|
22
|
+
* The `CronService` discovers all decorated methods automatically on module init.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* @Injectable()
|
|
26
|
+
* export class ReportService {
|
|
27
|
+
* @CronTask({ name: 'daily-report', cron: '0 8 * * *' })
|
|
28
|
+
* async run(job: Job) {
|
|
29
|
+
* // ...
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export const CronTask = (options: CronTaskOptions): MethodDecorator =>
|
|
34
|
+
SetMetadata(CRON_TASK_META, options)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common'
|
|
2
|
+
import { DiscoveryModule } from '@nestjs/core'
|
|
3
|
+
import { QueueModule } from '../queue.module.js'
|
|
4
|
+
import { CRON_QUEUE } from './cron.constants.js'
|
|
5
|
+
import { CronService } from './cron.service.js'
|
|
6
|
+
import { CronProcessor } from './cron.processor.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Global module that enables BullMQ-backed cron scheduling.
|
|
10
|
+
*
|
|
11
|
+
* Provides:
|
|
12
|
+
* - `CronService` — register tasks imperatively or via `@CronTask` decorator
|
|
13
|
+
* - `CronProcessor` — BullMQ worker that dispatches jobs to their handlers
|
|
14
|
+
*
|
|
15
|
+
* Requires `QueueModule.forRoot()` to be imported in the root module.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // AppModule
|
|
19
|
+
* imports: [
|
|
20
|
+
* QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
|
|
21
|
+
* CronModule,
|
|
22
|
+
* ]
|
|
23
|
+
*/
|
|
24
|
+
@Global()
|
|
25
|
+
@Module({
|
|
26
|
+
imports: [
|
|
27
|
+
DiscoveryModule,
|
|
28
|
+
QueueModule.register({ queues: [CRON_QUEUE] }),
|
|
29
|
+
],
|
|
30
|
+
providers: [CronService, CronProcessor],
|
|
31
|
+
exports: [CronService],
|
|
32
|
+
})
|
|
33
|
+
export class CronModule {}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'
|
|
2
|
+
import { Logger } from '@nestjs/common'
|
|
3
|
+
import type { Job, Queue } from 'bullmq'
|
|
4
|
+
import {
|
|
5
|
+
CRON_LOCK_PREFIX,
|
|
6
|
+
CRON_QUEUE,
|
|
7
|
+
DEFAULT_CRON_LOCK_TTL,
|
|
8
|
+
DEFAULT_CRON_WORKER_CONCURRENCY,
|
|
9
|
+
} from './cron.constants.js'
|
|
10
|
+
import type { CronService } from './cron.service.js'
|
|
11
|
+
|
|
12
|
+
@Processor(CRON_QUEUE, { concurrency: DEFAULT_CRON_WORKER_CONCURRENCY })
|
|
13
|
+
export class CronProcessor extends WorkerHost {
|
|
14
|
+
private readonly logger = new Logger(CronProcessor.name)
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly cronService: CronService,
|
|
18
|
+
@InjectQueue(CRON_QUEUE) private readonly cronQueue: Queue,
|
|
19
|
+
) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async process(job: Job<unknown, unknown, string>): Promise<unknown> {
|
|
24
|
+
const handler = this.cronService.getHandler(job.name)
|
|
25
|
+
if (!handler) {
|
|
26
|
+
const msg = `No cron handler registered for "${job.name}"`
|
|
27
|
+
this.logger.error(msg)
|
|
28
|
+
throw new Error(msg)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return this.cronService.shouldSkipIfRunning(job.name)
|
|
32
|
+
? this.processWithLock(job, handler)
|
|
33
|
+
: this.executeHandler(job, handler)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async processWithLock(
|
|
37
|
+
job: Job<unknown, unknown, string>,
|
|
38
|
+
handler: (job: Job) => unknown,
|
|
39
|
+
): Promise<unknown> {
|
|
40
|
+
const lockKey = `${CRON_LOCK_PREFIX}${job.name}`
|
|
41
|
+
const client = await this.cronQueue.client
|
|
42
|
+
|
|
43
|
+
// Atomic SET NX EX via the generic `runCommand` escape hatch on
|
|
44
|
+
// bullmq's `IRedisClient`. We can't use `client.set(..., { EX, NX })`
|
|
45
|
+
// because bullmq 5.77+ abstracted the client surface and dropped the
|
|
46
|
+
// `NX` option from `set`'s typed overloads (it now only accepts
|
|
47
|
+
// `{ PX?, EX? }`). `runCommand` is the documented portable way to
|
|
48
|
+
// reach any Redis command across ioredis / node-redis / bun-redis
|
|
49
|
+
// adapters. Returns the raw bulk-string reply ("OK") or nil.
|
|
50
|
+
const acquired = (await client.runCommand('set', [
|
|
51
|
+
lockKey,
|
|
52
|
+
job.id!,
|
|
53
|
+
'EX',
|
|
54
|
+
DEFAULT_CRON_LOCK_TTL,
|
|
55
|
+
'NX',
|
|
56
|
+
])) as string | null
|
|
57
|
+
if (!acquired) {
|
|
58
|
+
this.logger.warn(
|
|
59
|
+
`Skipping "${job.name}" (jobId=${job.id}) — previous instance still running`,
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return await this.executeHandler(job, handler)
|
|
66
|
+
} finally {
|
|
67
|
+
await client.del(lockKey)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async executeHandler(
|
|
72
|
+
job: Job<unknown, unknown, string>,
|
|
73
|
+
handler: (job: Job) => unknown,
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
const start = Date.now()
|
|
76
|
+
this.logger.log(`Cron task "${job.name}" started (jobId=${job.id})`)
|
|
77
|
+
try {
|
|
78
|
+
const result = await handler(job)
|
|
79
|
+
this.logger.log(`Cron task "${job.name}" completed in ${Date.now() - start}ms`)
|
|
80
|
+
return result
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
this.logger.error(`Cron task "${job.name}" failed after ${Date.now() - start}ms`, err)
|
|
83
|
+
throw err
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Logger,
|
|
4
|
+
type OnModuleDestroy,
|
|
5
|
+
type OnModuleInit,
|
|
6
|
+
} from '@nestjs/common'
|
|
7
|
+
import { DiscoveryService, Reflector } from '@nestjs/core'
|
|
8
|
+
import { InjectQueue } from '@nestjs/bullmq'
|
|
9
|
+
import type { Job, Queue } from 'bullmq'
|
|
10
|
+
import { CRON_QUEUE } from './cron.constants.js'
|
|
11
|
+
import type { CronHandler, CronTaskDefinition } from './cron.types.js'
|
|
12
|
+
import { CRON_TASK_META, type CronTaskOptions } from './cron-task.decorator.js'
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class CronService implements OnModuleInit, OnModuleDestroy {
|
|
16
|
+
private readonly logger = new Logger(CronService.name)
|
|
17
|
+
private readonly handlers = new Map<string, CronHandler>()
|
|
18
|
+
private readonly skipIfRunningSet = new Set<string>()
|
|
19
|
+
private readonly definitions: CronTaskDefinition[] = []
|
|
20
|
+
private initialized = false
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
@InjectQueue(CRON_QUEUE) private readonly cronQueue: Queue,
|
|
24
|
+
private readonly discoveryService: DiscoveryService,
|
|
25
|
+
private readonly reflector: Reflector,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register a cron task imperatively.
|
|
30
|
+
* If called before `onModuleInit`, it is picked up by `syncSchedulers`.
|
|
31
|
+
* If called after `onModuleInit`, the scheduler is upserted in BullMQ immediately.
|
|
32
|
+
*/
|
|
33
|
+
register<TData = unknown, TResult = unknown>(
|
|
34
|
+
definition: CronTaskDefinition<TData, TResult>,
|
|
35
|
+
): void {
|
|
36
|
+
if (this.handlers.has(definition.name)) {
|
|
37
|
+
throw new Error(`Cron task "${definition.name}" is already registered`)
|
|
38
|
+
}
|
|
39
|
+
this.handlers.set(definition.name, definition.handler as CronHandler)
|
|
40
|
+
if (definition.skipIfRunning) this.skipIfRunningSet.add(definition.name)
|
|
41
|
+
this.definitions.push(definition as CronTaskDefinition)
|
|
42
|
+
|
|
43
|
+
if (this.initialized) {
|
|
44
|
+
void this.upsertScheduler(definition as CronTaskDefinition)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getHandler<TData = unknown, TResult = unknown>(
|
|
49
|
+
name: string,
|
|
50
|
+
): CronHandler<TData, TResult> | undefined {
|
|
51
|
+
return this.handlers.get(name) as CronHandler<TData, TResult> | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
shouldSkipIfRunning(name: string): boolean {
|
|
55
|
+
return this.skipIfRunningSet.has(name)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async onModuleInit(): Promise<void> {
|
|
59
|
+
this.discoverCronTasks()
|
|
60
|
+
try {
|
|
61
|
+
await this.syncSchedulers()
|
|
62
|
+
} catch (err: unknown) {
|
|
63
|
+
this.logger.error('Failed to sync cron schedulers on init', err)
|
|
64
|
+
} finally {
|
|
65
|
+
this.initialized = true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async onModuleDestroy(): Promise<void> {
|
|
70
|
+
await this.cronQueue.close()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove specific cron tasks by name — deletes their schedulers and any
|
|
75
|
+
* waiting/delayed jobs from Redis.
|
|
76
|
+
*/
|
|
77
|
+
async purgeTasks(names: string[]): Promise<void> {
|
|
78
|
+
const schedulers = await this.cronQueue.getJobSchedulers()
|
|
79
|
+
for (const s of schedulers) {
|
|
80
|
+
if (names.includes(s.name)) {
|
|
81
|
+
try {
|
|
82
|
+
await this.cronQueue.removeJobScheduler(s.key)
|
|
83
|
+
this.logger.log(`Purged scheduler: ${s.name}`)
|
|
84
|
+
} catch (err: unknown) {
|
|
85
|
+
this.logger.error(`Failed to purge scheduler ${s.name}`, err)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const jobs = (await this.cronQueue.getJobs(['waiting', 'delayed'])) as Job[]
|
|
91
|
+
for (const j of jobs) {
|
|
92
|
+
if (!j) continue
|
|
93
|
+
if (names.includes(j.name)) {
|
|
94
|
+
try {
|
|
95
|
+
await j.remove()
|
|
96
|
+
this.logger.log(`Purged job: ${j.name} (id=${j.id})`)
|
|
97
|
+
} catch (err: unknown) {
|
|
98
|
+
this.logger.error(`Failed to purge job ${j.name}`, err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private discoverCronTasks(): void {
|
|
105
|
+
const providers = this.discoveryService.getProviders()
|
|
106
|
+
for (const wrapper of providers) {
|
|
107
|
+
const instance: unknown = wrapper.instance
|
|
108
|
+
if (!instance || typeof instance !== 'object') continue
|
|
109
|
+
|
|
110
|
+
const prototype = Object.getPrototypeOf(instance) as Record<string, unknown>
|
|
111
|
+
for (const key of Object.getOwnPropertyNames(prototype)) {
|
|
112
|
+
if (key === 'constructor') continue
|
|
113
|
+
|
|
114
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, key)
|
|
115
|
+
if (!descriptor || typeof descriptor.value !== 'function') continue
|
|
116
|
+
|
|
117
|
+
const meta = this.reflector.get<CronTaskOptions>(CRON_TASK_META, descriptor.value)
|
|
118
|
+
if (!meta) continue
|
|
119
|
+
|
|
120
|
+
const method = (instance as Record<string, unknown>)[key]
|
|
121
|
+
if (typeof method !== 'function') continue
|
|
122
|
+
this.register({
|
|
123
|
+
name: meta.name,
|
|
124
|
+
cron: meta.cron,
|
|
125
|
+
handler: (job) => method.call(instance, job),
|
|
126
|
+
skipIfRunning: meta.skipIfRunning,
|
|
127
|
+
opts: meta.opts,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Synchronise repeatable job schedulers in Redis with the current definitions.
|
|
135
|
+
* Removes stale schedulers no longer defined in code, then upserts all current ones.
|
|
136
|
+
*/
|
|
137
|
+
private async syncSchedulers(): Promise<void> {
|
|
138
|
+
const registeredNames = new Set(this.definitions.map((d) => d.name))
|
|
139
|
+
|
|
140
|
+
const existing = await this.cronQueue.getJobSchedulers()
|
|
141
|
+
for (const scheduler of existing) {
|
|
142
|
+
if (!registeredNames.has(scheduler.name)) {
|
|
143
|
+
try {
|
|
144
|
+
await this.cronQueue.removeJobScheduler(scheduler.key)
|
|
145
|
+
this.logger.log(
|
|
146
|
+
`Removed stale cron scheduler: ${scheduler.name} (key=${scheduler.key})`,
|
|
147
|
+
)
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
this.logger.error(`Failed to remove stale scheduler ${scheduler.name}`, err)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const def of this.definitions) {
|
|
155
|
+
await this.upsertScheduler(def)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async upsertScheduler(def: CronTaskDefinition): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
await this.cronQueue.upsertJobScheduler(
|
|
162
|
+
def.name,
|
|
163
|
+
{ pattern: def.cron },
|
|
164
|
+
{
|
|
165
|
+
name: def.name,
|
|
166
|
+
opts: {
|
|
167
|
+
removeOnComplete: { count: 100 },
|
|
168
|
+
removeOnFail: { count: 100 },
|
|
169
|
+
...def.opts,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
this.logger.log(`Cron task scheduled: "${def.name}" [${def.cron}]`)
|
|
174
|
+
} catch (err: unknown) {
|
|
175
|
+
this.logger.error(`Failed to schedule cron task "${def.name}"`, err)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Job, JobsOptions } from 'bullmq'
|
|
2
|
+
|
|
3
|
+
export type CronHandler<TData = unknown, TResult = unknown> = (
|
|
4
|
+
job: Job<TData, TResult, string>,
|
|
5
|
+
) => Promise<TResult> | TResult
|
|
6
|
+
|
|
7
|
+
export interface CronTaskDefinition<TData = unknown, TResult = unknown> {
|
|
8
|
+
name: string
|
|
9
|
+
cron: string
|
|
10
|
+
handler: CronHandler<TData, TResult>
|
|
11
|
+
skipIfRunning?: boolean
|
|
12
|
+
opts?: Omit<JobsOptions, 'repeat'>
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CronModule } from './cron.module.js'
|
|
2
|
+
export { CronService } from './cron.service.js'
|
|
3
|
+
export { CronProcessor } from './cron.processor.js'
|
|
4
|
+
export { CronTask, CRON_TASK_META, type CronTaskOptions } from './cron-task.decorator.js'
|
|
5
|
+
export type { CronHandler, CronTaskDefinition } from './cron.types.js'
|
|
6
|
+
export {
|
|
7
|
+
CRON_QUEUE,
|
|
8
|
+
CRON_LOCK_PREFIX,
|
|
9
|
+
DEFAULT_CRON_WORKER_CONCURRENCY,
|
|
10
|
+
DEFAULT_CRON_LOCK_TTL,
|
|
11
|
+
} from './cron.constants.js'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @modern-admin/queue — BullMQ queue + cron scheduling for NestJS.
|
|
2
|
+
|
|
3
|
+
export { QueueModule } from './queue.module.js'
|
|
4
|
+
export type { QueueModuleOptions, QueueRootOptions } from './queue.types.js'
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
CronModule,
|
|
8
|
+
CronService,
|
|
9
|
+
CronProcessor,
|
|
10
|
+
CronTask,
|
|
11
|
+
CRON_TASK_META,
|
|
12
|
+
CRON_QUEUE,
|
|
13
|
+
CRON_LOCK_PREFIX,
|
|
14
|
+
DEFAULT_CRON_WORKER_CONCURRENCY,
|
|
15
|
+
DEFAULT_CRON_LOCK_TTL,
|
|
16
|
+
type CronTaskOptions,
|
|
17
|
+
type CronHandler,
|
|
18
|
+
type CronTaskDefinition,
|
|
19
|
+
} from './cron'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type DynamicModule, Module } from '@nestjs/common'
|
|
2
|
+
import { BullModule } from '@nestjs/bullmq'
|
|
3
|
+
import type { QueueModuleOptions, QueueRootOptions } from './queue.types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BullMQ integration module for modern-admin NestJS applications.
|
|
7
|
+
*
|
|
8
|
+
* 1. Call `QueueModule.forRoot()` once in the root AppModule to configure the
|
|
9
|
+
* Redis connection globally.
|
|
10
|
+
* 2. Call `QueueModule.register({ queues: [...] })` in any feature module to
|
|
11
|
+
* register named queues and make their injection tokens available.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // AppModule
|
|
15
|
+
* imports: [
|
|
16
|
+
* QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
|
|
17
|
+
* QueueModule.register({ queues: ['emails', 'exports'] }),
|
|
18
|
+
* CronModule,
|
|
19
|
+
* ]
|
|
20
|
+
*/
|
|
21
|
+
@Module({})
|
|
22
|
+
export class QueueModule {
|
|
23
|
+
/**
|
|
24
|
+
* Configure the BullMQ Redis connection for the whole application.
|
|
25
|
+
* Must be imported once at the root level before any `register()` call.
|
|
26
|
+
*/
|
|
27
|
+
static forRoot(options: QueueRootOptions): DynamicModule {
|
|
28
|
+
return {
|
|
29
|
+
module: QueueModule,
|
|
30
|
+
global: true,
|
|
31
|
+
imports: [
|
|
32
|
+
BullModule.forRoot({
|
|
33
|
+
connection: options.connection as object,
|
|
34
|
+
defaultJobOptions: options.defaultJobOptions,
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Async variant of `forRoot` — useful when the Redis URL comes from a
|
|
42
|
+
* config service or environment variable loaded at runtime.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* QueueModule.forRootAsync({
|
|
46
|
+
* imports: [ConfigModule],
|
|
47
|
+
* inject: [ConfigService],
|
|
48
|
+
* useFactory: (cfg: ConfigService) => ({
|
|
49
|
+
* connection: cfg.get('REDIS_URL'),
|
|
50
|
+
* }),
|
|
51
|
+
* })
|
|
52
|
+
*/
|
|
53
|
+
static forRootAsync(opts: {
|
|
54
|
+
imports?: DynamicModule['imports']
|
|
55
|
+
inject?: unknown[]
|
|
56
|
+
useFactory: (...args: unknown[]) => QueueRootOptions | Promise<QueueRootOptions>
|
|
57
|
+
}): DynamicModule {
|
|
58
|
+
return {
|
|
59
|
+
module: QueueModule,
|
|
60
|
+
global: true,
|
|
61
|
+
imports: [
|
|
62
|
+
BullModule.forRootAsync({
|
|
63
|
+
imports: opts.imports,
|
|
64
|
+
inject: opts.inject as never[],
|
|
65
|
+
useFactory: async (...args: unknown[]) => {
|
|
66
|
+
const resolved = await opts.useFactory(...args)
|
|
67
|
+
return {
|
|
68
|
+
connection: resolved.connection as object,
|
|
69
|
+
defaultJobOptions: resolved.defaultJobOptions,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register named queues (and optional flow producers) in the current module.
|
|
79
|
+
* Exports the BullMQ tokens so they can be injected via `@InjectQueue(name)`.
|
|
80
|
+
*/
|
|
81
|
+
static register(options: QueueModuleOptions): DynamicModule {
|
|
82
|
+
const queueModules = options.queues.map((name) =>
|
|
83
|
+
BullModule.registerQueue({ name }),
|
|
84
|
+
)
|
|
85
|
+
const flowModules = (options.flows ?? []).map((name) =>
|
|
86
|
+
BullModule.registerFlowProducer({ name }),
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
module: QueueModule,
|
|
90
|
+
imports: [...queueModules, ...flowModules],
|
|
91
|
+
exports: [...queueModules, ...flowModules],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DefaultJobOptions } from 'bullmq'
|
|
2
|
+
|
|
3
|
+
export interface QueueModuleOptions {
|
|
4
|
+
queues: string[]
|
|
5
|
+
flows?: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface QueueRootOptions {
|
|
9
|
+
/**
|
|
10
|
+
* ioredis-compatible connection. Accepts a connection string
|
|
11
|
+
* (`redis://...`) or a plain options object.
|
|
12
|
+
*/
|
|
13
|
+
connection: { host?: string; port?: number; password?: string; db?: number } | string
|
|
14
|
+
/** Default job options applied to every queue in this process. */
|
|
15
|
+
defaultJobOptions?: DefaultJobOptions
|
|
16
|
+
}
|