@bymax-one/nest-queue 1.2.9 → 1.2.11
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/CHANGELOG.md +102 -1
- package/README.md +27 -16
- package/dist/server/index.cjs +59 -484
- package/dist/server/index.d.cts +44 -14
- package/dist/server/index.d.ts +44 -14
- package/dist/server/index.mjs +60 -485
- package/package.json +3 -2
package/dist/server/index.cjs
CHANGED
|
@@ -165,7 +165,6 @@ function applyDefaults(opts) {
|
|
|
165
165
|
Object.defineProperty(base, "connection", {
|
|
166
166
|
get: () => connection,
|
|
167
167
|
enumerable: false,
|
|
168
|
-
// Stryker disable next-line BooleanLiteral: equivalent HERE — this object is `Object.freeze`d on the way out, and freezing makes every property non-configurable regardless. The flag stays because it states the guarantee at the point the accessor is defined, and because the sibling packages that withhold secrets the same way are NOT frozen, where it is load-bearing
|
|
169
168
|
configurable: false
|
|
170
169
|
});
|
|
171
170
|
return Object.freeze(base);
|
|
@@ -202,21 +201,11 @@ function duplicateConnection(client) {
|
|
|
202
201
|
var _client, _options;
|
|
203
202
|
exports.ConnectionResolver = class ConnectionResolver {
|
|
204
203
|
constructor(options) {
|
|
205
|
-
/**
|
|
206
|
-
* The resolved Redis client.
|
|
207
|
-
*
|
|
208
|
-
* An ECMAScript private field rather than a TypeScript `private` one, which
|
|
209
|
-
* is erased at runtime: an ioredis instance carries `options.password` as a
|
|
210
|
-
* plain field, so leaving this enumerable would let anything that serializes
|
|
211
|
-
* a service holding this resolver walk into the credentials.
|
|
212
|
-
*/
|
|
213
204
|
__privateAdd(this, _client);
|
|
214
|
-
/** The consumer's module options, which carry the Redis credentials. */
|
|
215
205
|
__privateAdd(this, _options);
|
|
216
206
|
__publicField(this, "mode");
|
|
217
207
|
__privateSet(this, _options, options);
|
|
218
208
|
}
|
|
219
|
-
/** Resolve and validate the connection. Call once during module bootstrap. */
|
|
220
209
|
async init() {
|
|
221
210
|
const cfg = __privateGet(this, _options).connection;
|
|
222
211
|
if ("client" in cfg) {
|
|
@@ -229,18 +218,9 @@ exports.ConnectionResolver = class ConnectionResolver {
|
|
|
229
218
|
__privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
|
|
230
219
|
);
|
|
231
220
|
}
|
|
232
|
-
/**
|
|
233
|
-
* Merge the consumer's options with the owned-connection policy
|
|
234
|
-
* (`lazyConnect: false`, so the client connects eagerly and `init` can wait
|
|
235
|
-
* for `ready`) and narrow the result to the shape ioredis 6's constructor
|
|
236
|
-
* accepts. The runtime object is unchanged — the cast only drops the
|
|
237
|
-
* `undefined` that `RedisOptions.replyMapping` carries and the constructor
|
|
238
|
-
* rejects.
|
|
239
|
-
*/
|
|
240
221
|
ownedOptions(options) {
|
|
241
222
|
return { ...options ?? {}, lazyConnect: false };
|
|
242
223
|
}
|
|
243
|
-
/** The resolved Queue-role client. */
|
|
244
224
|
getClient() {
|
|
245
225
|
if (!__privateGet(this, _client)) {
|
|
246
226
|
throw new QueueException(
|
|
@@ -253,7 +233,6 @@ exports.ConnectionResolver = class ConnectionResolver {
|
|
|
253
233
|
}
|
|
254
234
|
return __privateGet(this, _client);
|
|
255
235
|
}
|
|
256
|
-
/** The resolved connection mode. */
|
|
257
236
|
getMode() {
|
|
258
237
|
if (!this.mode) {
|
|
259
238
|
throw new QueueException(
|
|
@@ -266,11 +245,9 @@ exports.ConnectionResolver = class ConnectionResolver {
|
|
|
266
245
|
}
|
|
267
246
|
return this.mode;
|
|
268
247
|
}
|
|
269
|
-
/** Whether the library owns (and must close) the connection. */
|
|
270
248
|
isOwned() {
|
|
271
249
|
return this.mode === "mode-b-owned";
|
|
272
250
|
}
|
|
273
|
-
/** Close the library-owned connection on shutdown; never touch a BYO client. */
|
|
274
251
|
async teardown() {
|
|
275
252
|
if (this.isOwned() && __privateGet(this, _client)) {
|
|
276
253
|
const client = __privateGet(this, _client);
|
|
@@ -279,7 +256,6 @@ exports.ConnectionResolver = class ConnectionResolver {
|
|
|
279
256
|
});
|
|
280
257
|
}
|
|
281
258
|
}
|
|
282
|
-
/** Validate and adopt a bring-your-own client for the Queue role. */
|
|
283
259
|
initModeA(client) {
|
|
284
260
|
this.mode = "mode-a-byo";
|
|
285
261
|
__privateSet(this, _client, client);
|
|
@@ -297,7 +273,6 @@ exports.ConnectionResolver = class ConnectionResolver {
|
|
|
297
273
|
probe.disconnect();
|
|
298
274
|
}
|
|
299
275
|
}
|
|
300
|
-
/** Resolve when the owned client reaches `ready`, or reject on timeout/error. */
|
|
301
276
|
async waitReady(timeoutMs) {
|
|
302
277
|
const client = this.getClient();
|
|
303
278
|
if (client.status === "ready") return;
|
|
@@ -389,8 +364,15 @@ function attachDefaultErrorListener(emitter, logger, kind, queueName) {
|
|
|
389
364
|
|
|
390
365
|
// src/server/services/queue.service.ts
|
|
391
366
|
var MAX_BULK_SIZE = 1e3;
|
|
392
|
-
function
|
|
393
|
-
|
|
367
|
+
function assertValidCron(repeat, queueOptions) {
|
|
368
|
+
if (queueOptions.settings?.repeatStrategy !== void 0) return;
|
|
369
|
+
try {
|
|
370
|
+
bullmq.defaultRepeatStrategy(Date.now(), repeat);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
|
|
373
|
+
reason: "pattern must be a valid cron expression (5- or 6-field)"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
394
376
|
}
|
|
395
377
|
var _queues;
|
|
396
378
|
exports.QueueService = class QueueService {
|
|
@@ -402,14 +384,6 @@ exports.QueueService = class QueueService {
|
|
|
402
384
|
}
|
|
403
385
|
connection;
|
|
404
386
|
options;
|
|
405
|
-
/**
|
|
406
|
-
* Return the cached `Queue` for `queueName`, or create one with the module
|
|
407
|
-
* defaults applied. Subsequent calls with the same name return the same
|
|
408
|
-
* instance.
|
|
409
|
-
*
|
|
410
|
-
* @param queueName - Unique queue name (used as the Redis key prefix).
|
|
411
|
-
* @param overrides - Optional per-queue option overrides.
|
|
412
|
-
*/
|
|
413
387
|
getOrCreateQueue(queueName, overrides) {
|
|
414
388
|
const existing = __privateGet(this, _queues).get(queueName);
|
|
415
389
|
if (existing) return existing;
|
|
@@ -425,70 +399,13 @@ exports.QueueService = class QueueService {
|
|
|
425
399
|
__privateGet(this, _queues).set(queueName, queue);
|
|
426
400
|
return queue;
|
|
427
401
|
}
|
|
428
|
-
/** Resolve the raw cached queue (default generics) for internal delegation. */
|
|
429
402
|
queueFor(queueName) {
|
|
430
403
|
return this.getOrCreateQueue(queueName);
|
|
431
404
|
}
|
|
432
|
-
/**
|
|
433
|
-
* Add a single job to a queue. The queue is created lazily if needed.
|
|
434
|
-
*
|
|
435
|
-
* Delivery is **at-least-once** — a handler may run more than once for the same
|
|
436
|
-
* job (worker crash, lock expiry, or a shutdown force-close). Make handlers
|
|
437
|
-
* idempotent: use an idempotency key on writes, prefer upserts over inserts, or
|
|
438
|
-
* keep an "already-processed" marker keyed by `job.id`.
|
|
439
|
-
*
|
|
440
|
-
* `options` surfaces BullMQ natives directly — including `jobId` (idempotent
|
|
441
|
-
* insert: a second add with the same id is a no-op while the first exists) and
|
|
442
|
-
* `deduplication` (windowed deduplication). The library writes no deduplication
|
|
443
|
-
* code; BullMQ owns the behavior. The deduplication key is independent of
|
|
444
|
-
* `jobId`, so both can be set together or apart. Both collapse duplicate
|
|
445
|
-
* **producers** and do not change the at-least-once guarantee on the
|
|
446
|
-
* **consumer** side.
|
|
447
|
-
*
|
|
448
|
-
* The deduplication `id` is a shared key across the queue, so a multi-tenant
|
|
449
|
-
* producer MUST scope it by tenant: keyed on the value alone
|
|
450
|
-
* (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
|
|
451
|
-
* for the same term.
|
|
452
|
-
*
|
|
453
|
-
* Scoping alone is not enough. Two variable fields joined by a delimiter that
|
|
454
|
-
* either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
|
|
455
|
-
* the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
|
|
456
|
-
* every variable field so the delimiter cannot appear inside one:
|
|
457
|
-
* `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
|
|
458
|
-
*
|
|
459
|
-
* Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
|
|
460
|
-
* - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
|
|
461
|
-
* - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
|
|
462
|
-
* - Debounce `{ id, ttl, extend: true, replace: true }` — keep only the latest
|
|
463
|
-
* data; each duplicate resets the TTL.
|
|
464
|
-
* - keep-last-if-active `{ id, keepLastIfActive: true }` — while a job is active,
|
|
465
|
-
* store the latest data and run a single follow-up when it finishes.
|
|
466
|
-
*
|
|
467
|
-
* @param queueName - Target queue (created lazily if absent).
|
|
468
|
-
* @param jobName - Job identifier used by `@Process` to dispatch.
|
|
469
|
-
* @param data - Typed job payload.
|
|
470
|
-
* @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
|
|
471
|
-
* @returns The created job.
|
|
472
|
-
* @example Throttle: at most one reindex per term every 5 seconds. Both variable
|
|
473
|
-
* fields are percent-encoded, so no other tenant/term pair can build this key.
|
|
474
|
-
* const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
|
|
475
|
-
* await queueService.enqueue('search', 'reindex', { term }, {
|
|
476
|
-
* deduplication: { id: dedupId, ttl: 5_000 },
|
|
477
|
-
* })
|
|
478
|
-
*/
|
|
479
405
|
async enqueue(queueName, jobName, data, options) {
|
|
480
406
|
const job = await this.queueFor(queueName).add(jobName, data, options);
|
|
481
407
|
return job;
|
|
482
408
|
}
|
|
483
|
-
/**
|
|
484
|
-
* Enqueue multiple jobs in a single Redis roundtrip. The batch is bounded by
|
|
485
|
-
* {@link MAX_BULK_SIZE} to guard against a self-inflicted memory spike; any
|
|
486
|
-
* failure is wrapped in a `QueueException`. Delivery is at-least-once, so every
|
|
487
|
-
* job's handler must be idempotent (see {@link QueueService.enqueue}).
|
|
488
|
-
*
|
|
489
|
-
* @param queueName - Target queue.
|
|
490
|
-
* @param jobs - Job descriptors (length must not exceed the bulk size cap).
|
|
491
|
-
*/
|
|
492
409
|
async enqueueBulk(queueName, jobs) {
|
|
493
410
|
if (jobs.length > MAX_BULK_SIZE) {
|
|
494
411
|
throw new QueueException(
|
|
@@ -520,33 +437,14 @@ exports.QueueService = class QueueService {
|
|
|
520
437
|
);
|
|
521
438
|
}
|
|
522
439
|
}
|
|
523
|
-
/**
|
|
524
|
-
* Fetch a job by id. Returns `null` when the job does not exist.
|
|
525
|
-
*
|
|
526
|
-
* @param queueName - Target queue.
|
|
527
|
-
* @param jobId - The job identifier.
|
|
528
|
-
*/
|
|
529
440
|
async getJob(queueName, jobId) {
|
|
530
441
|
const job = await this.queueFor(queueName).getJob(jobId);
|
|
531
442
|
return job ?? null;
|
|
532
443
|
}
|
|
533
|
-
/**
|
|
534
|
-
* Fetch jobs in a given status with pagination.
|
|
535
|
-
*
|
|
536
|
-
* @param queueName - Target queue.
|
|
537
|
-
* @param status - Status filter.
|
|
538
|
-
* @param start - Starting index (inclusive). Default: 0.
|
|
539
|
-
* @param end - Ending index (inclusive). Default: 50.
|
|
540
|
-
*/
|
|
541
444
|
async getJobs(queueName, status, start = 0, end = 50) {
|
|
542
445
|
const jobs = await this.queueFor(queueName).getJobs([status], start, end);
|
|
543
446
|
return jobs;
|
|
544
447
|
}
|
|
545
|
-
/**
|
|
546
|
-
* Return an uncached snapshot of job counts grouped by status.
|
|
547
|
-
*
|
|
548
|
-
* @param queueName - Target queue.
|
|
549
|
-
*/
|
|
550
448
|
async getMetrics(queueName) {
|
|
551
449
|
const counts = await this.queueFor(queueName).getJobCounts(
|
|
552
450
|
"waiting",
|
|
@@ -561,24 +459,6 @@ exports.QueueService = class QueueService {
|
|
|
561
459
|
collectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
562
460
|
};
|
|
563
461
|
}
|
|
564
|
-
/**
|
|
565
|
-
* Create or update a Job Scheduler — the current BullMQ recurring-jobs API.
|
|
566
|
-
* Idempotent by `schedulerId`: BullMQ performs an atomic `override: true`
|
|
567
|
-
* upsert, so re-registering on every boot updates the schedule in place and
|
|
568
|
-
* never duplicates it. This supersedes the repeatable-jobs surface removed in
|
|
569
|
-
* BullMQ v6.
|
|
570
|
-
*
|
|
571
|
-
* The schedule is structurally validated first; the cron string itself is
|
|
572
|
-
* parsed by BullMQ at registration time, and a parse failure is rethrown as
|
|
573
|
-
* `INVALID_REPEAT_OPTIONS` (400).
|
|
574
|
-
*
|
|
575
|
-
* @param queueName - Target queue.
|
|
576
|
-
* @param schedulerId - Stable, unique scheduler identifier (the upsert key).
|
|
577
|
-
* @param repeat - Cron `pattern` OR `every` (ms), plus optional tz/limit/start/end.
|
|
578
|
-
* @param template - Optional job template; `name` defaults to `schedulerId`, `data` to `{}`.
|
|
579
|
-
* @returns The first scheduled (delayed) job, or `undefined`.
|
|
580
|
-
* @throws {QueueException} `INVALID_REPEAT_OPTIONS` (400) for an invalid schedule.
|
|
581
|
-
*/
|
|
582
462
|
async upsertJobScheduler(queueName, schedulerId, repeat, template) {
|
|
583
463
|
validateJobSchedulerOptions(repeat);
|
|
584
464
|
const queue = this.queueFor(queueName);
|
|
@@ -587,76 +467,28 @@ exports.QueueService = class QueueService {
|
|
|
587
467
|
data: template?.data ?? {},
|
|
588
468
|
...template?.opts === void 0 ? {} : { opts: template.opts }
|
|
589
469
|
};
|
|
590
|
-
if ("pattern" in repeat)
|
|
591
|
-
try {
|
|
592
|
-
const job2 = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
|
|
593
|
-
return job2;
|
|
594
|
-
} catch (err) {
|
|
595
|
-
if (isOperationalError(err)) throw err;
|
|
596
|
-
throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
|
|
597
|
-
reason: "pattern must be a valid cron expression (5- or 6-field)"
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
}
|
|
470
|
+
if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
|
|
601
471
|
const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
|
|
602
472
|
return job;
|
|
603
473
|
}
|
|
604
|
-
/**
|
|
605
|
-
* Remove a Job Scheduler by id.
|
|
606
|
-
*
|
|
607
|
-
* @param queueName - Target queue.
|
|
608
|
-
* @param schedulerId - The scheduler identifier to remove.
|
|
609
|
-
* @returns `true` when a scheduler was removed, `false` otherwise.
|
|
610
|
-
*/
|
|
611
474
|
async removeJobScheduler(queueName, schedulerId) {
|
|
612
475
|
return this.queueFor(queueName).removeJobScheduler(schedulerId);
|
|
613
476
|
}
|
|
614
|
-
/**
|
|
615
|
-
* List the Job Schedulers registered on a queue, for inspection or health checks.
|
|
616
|
-
*
|
|
617
|
-
* @param queueName - Target queue.
|
|
618
|
-
* @param start - Page start index (inclusive). Default: 0.
|
|
619
|
-
* @param end - Page end index (inclusive). Default: 50.
|
|
620
|
-
* @param asc - Sort ascending by next run time. Default: true.
|
|
621
|
-
* @returns The registered schedulers in the requested page.
|
|
622
|
-
*/
|
|
623
477
|
async getJobSchedulers(queueName, start = 0, end = 50, asc = true) {
|
|
624
478
|
return this.queueFor(queueName).getJobSchedulers(start, end, asc);
|
|
625
479
|
}
|
|
626
|
-
/** Pause a queue, halting the processing of new jobs. */
|
|
627
480
|
async pauseQueue(queueName) {
|
|
628
481
|
await this.queueFor(queueName).pause();
|
|
629
482
|
}
|
|
630
|
-
/** Resume a previously paused queue. */
|
|
631
483
|
async resumeQueue(queueName) {
|
|
632
484
|
await this.queueFor(queueName).resume();
|
|
633
485
|
}
|
|
634
|
-
/**
|
|
635
|
-
* Remove up to `limit` jobs older than `gracePeriodMs` in a given status. The
|
|
636
|
-
* argument order mirrors BullMQ's `Queue.clean(grace, limit, type?)` exactly;
|
|
637
|
-
* `limit` is required and `0` means no limit. Returns the removed job ids.
|
|
638
|
-
*
|
|
639
|
-
* @param queueName - Target queue.
|
|
640
|
-
* @param gracePeriodMs - Keep jobs younger than this many milliseconds.
|
|
641
|
-
* @param limit - Maximum number of jobs to remove (`0` = no limit).
|
|
642
|
-
* @param status - Status of jobs to clean.
|
|
643
|
-
*/
|
|
644
486
|
async cleanQueue(queueName, gracePeriodMs, limit, status) {
|
|
645
487
|
return this.queueFor(queueName).clean(gracePeriodMs, limit, status);
|
|
646
488
|
}
|
|
647
|
-
/** Return a read-only view of the cached queues. */
|
|
648
489
|
getCachedQueues() {
|
|
649
490
|
return __privateGet(this, _queues);
|
|
650
491
|
}
|
|
651
|
-
/**
|
|
652
|
-
* Close every cached queue (swallowing per-queue errors) and clear the cache.
|
|
653
|
-
*
|
|
654
|
-
* Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
|
|
655
|
-
* method name, so a hook here would run on Nest's own schedule, concurrently
|
|
656
|
-
* with — and possibly before — the bounded drain in `QueueLifecycle`. Closing a
|
|
657
|
-
* queue while its worker is still draining is exactly what the ordered sequence
|
|
658
|
-
* exists to prevent. `QueueLifecycle` is the only caller.
|
|
659
|
-
*/
|
|
660
492
|
async closeAll() {
|
|
661
493
|
for (const queue of __privateGet(this, _queues).values()) {
|
|
662
494
|
await queue.close().catch(() => void 0);
|
|
@@ -677,73 +509,26 @@ exports.FlowService = class FlowService {
|
|
|
677
509
|
if (enabled) {
|
|
678
510
|
this.producer = new bullmq.FlowProducer({
|
|
679
511
|
connection: connection.getClient(),
|
|
680
|
-
// Match the producer Queue's key prefix so flow jobs land in the same
|
|
681
|
-
// keyspace the workers poll; a non-default prefix breaks flows otherwise.
|
|
682
512
|
prefix,
|
|
683
513
|
...telemetry ? { telemetry } : {}
|
|
684
514
|
});
|
|
685
515
|
attachDefaultErrorListener(this.producer, this.logger, "FlowProducer", "*");
|
|
686
516
|
}
|
|
687
517
|
}
|
|
688
|
-
/**
|
|
689
|
-
* Add a flow tree (a parent with its descendant children). The root job
|
|
690
|
-
* becomes processable only after every descendant has completed.
|
|
691
|
-
*
|
|
692
|
-
* By BullMQ default a failed (retry-exhausted) child does NOT fail its parent:
|
|
693
|
-
* the parent stays in the `waiting-children` state until the child eventually
|
|
694
|
-
* completes. To make a child's failure propagate upward and fail its parent,
|
|
695
|
-
* set `failParentOnFailure: true` on that child (it cascades when ancestors
|
|
696
|
-
* also set it). To let a parent proceed despite a failed child, set
|
|
697
|
-
* `ignoreDependencyOnFailure: true` on that child. Bound each child's
|
|
698
|
-
* `attempts` so an unrecoverable child cannot leave the parent waiting forever.
|
|
699
|
-
*
|
|
700
|
-
* @param flow - Flow tree definition.
|
|
701
|
-
* @returns The root `JobNode` containing the created job and its children.
|
|
702
|
-
* @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
|
|
703
|
-
*/
|
|
704
518
|
async add(flow) {
|
|
705
519
|
return this.requireProducer().add(flow);
|
|
706
520
|
}
|
|
707
|
-
/**
|
|
708
|
-
* Add multiple flow trees in a single Redis roundtrip.
|
|
709
|
-
*
|
|
710
|
-
* @param flows - Flow tree definitions.
|
|
711
|
-
* @returns The root `JobNode` for each flow, in input order.
|
|
712
|
-
* @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
|
|
713
|
-
*/
|
|
714
521
|
async addBulk(flows) {
|
|
715
522
|
return this.requireProducer().addBulk([...flows]);
|
|
716
523
|
}
|
|
717
|
-
/**
|
|
718
|
-
* Escape hatch returning the underlying `FlowProducer` for advanced use cases.
|
|
719
|
-
* Prefer {@link FlowService.add} / {@link FlowService.addBulk} when possible.
|
|
720
|
-
*
|
|
721
|
-
* @returns The underlying BullMQ `FlowProducer`.
|
|
722
|
-
* @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
|
|
723
|
-
*/
|
|
724
524
|
getProducer() {
|
|
725
525
|
return this.requireProducer();
|
|
726
526
|
}
|
|
727
|
-
/**
|
|
728
|
-
* Close the producer when active; a no-op when inactive. A failed `close()` is
|
|
729
|
-
* swallowed so it never aborts the shutdown sequence.
|
|
730
|
-
*
|
|
731
|
-
* Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
|
|
732
|
-
* method name, so a hook here would run in addition to the call the ordered
|
|
733
|
-
* sequence already makes, closing the producer twice. `QueueLifecycle` is the
|
|
734
|
-
* only caller.
|
|
735
|
-
*/
|
|
736
527
|
async close() {
|
|
737
528
|
if (this.producer) {
|
|
738
529
|
await this.producer.close().catch(() => void 0);
|
|
739
530
|
}
|
|
740
531
|
}
|
|
741
|
-
/**
|
|
742
|
-
* Return the active producer or throw when flows are disabled. The producer is
|
|
743
|
-
* present if and only if flows were enabled at construction time.
|
|
744
|
-
*
|
|
745
|
-
* @throws {QueueException} `FLOW_DISABLED` (503) when no producer exists.
|
|
746
|
-
*/
|
|
747
532
|
requireProducer() {
|
|
748
533
|
if (!this.producer) {
|
|
749
534
|
throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
|
|
@@ -764,14 +549,6 @@ exports.MetricsService = class MetricsService {
|
|
|
764
549
|
enabled;
|
|
765
550
|
ttlMs;
|
|
766
551
|
cache = /* @__PURE__ */ new Map();
|
|
767
|
-
/**
|
|
768
|
-
* Return cached metrics for `queueName`, or fetch fresh (and cache them) on a
|
|
769
|
-
* miss or after expiry.
|
|
770
|
-
*
|
|
771
|
-
* @param queueName - Target queue.
|
|
772
|
-
* @returns The current metrics snapshot.
|
|
773
|
-
* @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
|
|
774
|
-
*/
|
|
775
552
|
async get(queueName) {
|
|
776
553
|
this.ensureEnabled();
|
|
777
554
|
const entry = this.cache.get(queueName);
|
|
@@ -782,23 +559,11 @@ exports.MetricsService = class MetricsService {
|
|
|
782
559
|
this.cache.set(queueName, { metrics, expiresAt: Date.now() + this.ttlMs });
|
|
783
560
|
return metrics;
|
|
784
561
|
}
|
|
785
|
-
/**
|
|
786
|
-
* Return a metrics snapshot for every queue currently cached in `QueueService`.
|
|
787
|
-
*
|
|
788
|
-
* @returns Metrics for all known queues.
|
|
789
|
-
* @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
|
|
790
|
-
*/
|
|
791
562
|
async getAll() {
|
|
792
563
|
this.ensureEnabled();
|
|
793
564
|
const names = Array.from(this.queueService.getCachedQueues().keys());
|
|
794
565
|
return Promise.all(names.map((name) => this.get(name)));
|
|
795
566
|
}
|
|
796
|
-
/**
|
|
797
|
-
* Invalidate the cache for `queueName`, or the whole cache when omitted.
|
|
798
|
-
*
|
|
799
|
-
* @param queueName - The queue entry to drop; omit to clear everything.
|
|
800
|
-
* @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
|
|
801
|
-
*/
|
|
802
567
|
invalidate(queueName) {
|
|
803
568
|
this.ensureEnabled();
|
|
804
569
|
if (queueName === void 0) {
|
|
@@ -807,11 +572,6 @@ exports.MetricsService = class MetricsService {
|
|
|
807
572
|
}
|
|
808
573
|
this.cache.delete(queueName);
|
|
809
574
|
}
|
|
810
|
-
/**
|
|
811
|
-
* Guard every operation behind the opt-in flag.
|
|
812
|
-
*
|
|
813
|
-
* @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
|
|
814
|
-
*/
|
|
815
575
|
ensureEnabled() {
|
|
816
576
|
if (!this.enabled) {
|
|
817
577
|
throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
|
|
@@ -831,19 +591,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
831
591
|
}
|
|
832
592
|
connection;
|
|
833
593
|
options;
|
|
834
|
-
/**
|
|
835
|
-
* Register an in-process worker with a handler function. The BullMQ `Worker`
|
|
836
|
-
* constructor is synchronous — no Redis handshake is performed at registration
|
|
837
|
-
* time. The duplicated connection is disconnected immediately if construction
|
|
838
|
-
* fails, preventing a Redis connection leak.
|
|
839
|
-
*
|
|
840
|
-
* @param config - Queue name, handler, and optional worker options.
|
|
841
|
-
* @returns The created BullMQ `Worker` instance.
|
|
842
|
-
* @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
|
|
843
|
-
* `queueName` already exists.
|
|
844
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
|
|
845
|
-
* invalid or the BullMQ `Worker` constructor throws.
|
|
846
|
-
*/
|
|
847
594
|
register(config) {
|
|
848
595
|
const { queueName, handler, options } = config;
|
|
849
596
|
this.guardDuplicate(queueName);
|
|
@@ -868,21 +615,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
868
615
|
__privateGet(this, _entries).set(queueName, { worker, connection: conn });
|
|
869
616
|
return worker;
|
|
870
617
|
}
|
|
871
|
-
/**
|
|
872
|
-
* Register a sandboxed (file-based, out-of-process) worker. The processor
|
|
873
|
-
* file must be an absolute path or a `file:` URL pointing to a compiled
|
|
874
|
-
* `.js`/`.cjs`/`.mjs` artifact. NestJS DI is **not** available inside the
|
|
875
|
-
* processor. The BullMQ `Worker` constructor is synchronous.
|
|
876
|
-
*
|
|
877
|
-
* @param config - Queue name, processor file path/URL, and optional options.
|
|
878
|
-
* @returns The created BullMQ `Worker` instance.
|
|
879
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` when `processorFile`
|
|
880
|
-
* is a relative path, has a disallowed extension, or uses a non-`file:` URL protocol.
|
|
881
|
-
* @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
|
|
882
|
-
* `queueName` already exists.
|
|
883
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
|
|
884
|
-
* invalid or the BullMQ `Worker` constructor throws.
|
|
885
|
-
*/
|
|
886
618
|
registerSandboxed(config) {
|
|
887
619
|
const { queueName, processorFile, options } = config;
|
|
888
620
|
this.validateProcessorFile(processorFile);
|
|
@@ -911,13 +643,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
911
643
|
__privateGet(this, _entries).set(queueName, { worker, connection: conn });
|
|
912
644
|
return worker;
|
|
913
645
|
}
|
|
914
|
-
/**
|
|
915
|
-
* Stop and remove the worker for the given queue, releasing the duplicated
|
|
916
|
-
* connection the library opened for it. If no worker exists for `queueName`
|
|
917
|
-
* this is a no-op.
|
|
918
|
-
*
|
|
919
|
-
* @param queueName - The queue whose worker should be removed.
|
|
920
|
-
*/
|
|
921
646
|
async unregister(queueName) {
|
|
922
647
|
const entry = __privateGet(this, _entries).get(queueName);
|
|
923
648
|
if (!entry) return;
|
|
@@ -925,44 +650,19 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
925
650
|
await this.closeConnection(entry.connection);
|
|
926
651
|
__privateGet(this, _entries).delete(queueName);
|
|
927
652
|
}
|
|
928
|
-
/**
|
|
929
|
-
* Returns the queue names of all currently registered workers.
|
|
930
|
-
*
|
|
931
|
-
* @returns An immutable array of queue names.
|
|
932
|
-
*/
|
|
933
653
|
list() {
|
|
934
654
|
return Array.from(__privateGet(this, _entries).keys());
|
|
935
655
|
}
|
|
936
|
-
/**
|
|
937
|
-
* Returns a read-only view of the registered workers keyed by queue name.
|
|
938
|
-
* Intended for the shutdown orchestrator only.
|
|
939
|
-
*
|
|
940
|
-
* @returns A read-only map of queue name to worker.
|
|
941
|
-
*/
|
|
942
656
|
getAll() {
|
|
943
657
|
return new Map(
|
|
944
658
|
Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.worker])
|
|
945
659
|
);
|
|
946
660
|
}
|
|
947
|
-
/**
|
|
948
|
-
* Returns a read-only view of the duplicated connections the library opened
|
|
949
|
-
* for its workers, keyed by queue name. Intended for the shutdown orchestrator
|
|
950
|
-
* so it can close exactly the connections the library created (Mode A never
|
|
951
|
-
* exposes the consumer's shared client here).
|
|
952
|
-
*
|
|
953
|
-
* @returns A read-only map of queue name to duplicated connection.
|
|
954
|
-
*/
|
|
955
661
|
getConnections() {
|
|
956
662
|
return new Map(
|
|
957
663
|
Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.connection])
|
|
958
664
|
);
|
|
959
665
|
}
|
|
960
|
-
/**
|
|
961
|
-
* Gracefully quit a duplicated connection, falling back to a hard disconnect
|
|
962
|
-
* if the quit handshake fails so a socket is never leaked.
|
|
963
|
-
*
|
|
964
|
-
* @param connection - The duplicated connection to release.
|
|
965
|
-
*/
|
|
966
666
|
async closeConnection(connection) {
|
|
967
667
|
try {
|
|
968
668
|
await connection.quit();
|
|
@@ -970,12 +670,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
970
670
|
connection.disconnect();
|
|
971
671
|
}
|
|
972
672
|
}
|
|
973
|
-
/**
|
|
974
|
-
* Guard against registering two workers for the same queue.
|
|
975
|
-
*
|
|
976
|
-
* @param queueName - Queue name to check.
|
|
977
|
-
* @throws {QueueException} `DUPLICATE_PROCESSOR` if the queue is already registered.
|
|
978
|
-
*/
|
|
979
673
|
guardDuplicate(queueName) {
|
|
980
674
|
if (__privateGet(this, _entries).has(queueName)) {
|
|
981
675
|
throw new QueueException(
|
|
@@ -985,14 +679,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
985
679
|
);
|
|
986
680
|
}
|
|
987
681
|
}
|
|
988
|
-
/**
|
|
989
|
-
* Validate tunable options before constructing a Worker.
|
|
990
|
-
* Enforces `concurrency` in `[1, MAX_WORKER_CONCURRENCY]` and
|
|
991
|
-
* `limiter.max`/`limiter.duration` `>= 1`.
|
|
992
|
-
*
|
|
993
|
-
* @param opts - The caller-supplied options (may be undefined).
|
|
994
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` when values are out of range.
|
|
995
|
-
*/
|
|
996
682
|
validateOptions(opts) {
|
|
997
683
|
if (opts?.concurrency !== void 0) {
|
|
998
684
|
if (opts.concurrency < 1) {
|
|
@@ -1039,15 +725,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
1039
725
|
}
|
|
1040
726
|
}
|
|
1041
727
|
}
|
|
1042
|
-
/**
|
|
1043
|
-
* Validate that `processorFile` is a safe, reachable artifact path. Accepts
|
|
1044
|
-
* absolute paths with `.js`/`.cjs`/`.mjs` extensions and `file:` URLs. Rejects
|
|
1045
|
-
* relative paths, non-JS extensions, and non-`file:` URL protocols to prevent
|
|
1046
|
-
* path-traversal and arbitrary-file-execution attacks.
|
|
1047
|
-
*
|
|
1048
|
-
* @param processorFile - The path or URL supplied by the caller.
|
|
1049
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` when validation fails.
|
|
1050
|
-
*/
|
|
1051
728
|
validateProcessorFile(processorFile) {
|
|
1052
729
|
if (processorFile instanceof URL) {
|
|
1053
730
|
if (processorFile.protocol !== "file:") {
|
|
@@ -1073,13 +750,6 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
1073
750
|
}
|
|
1074
751
|
this.assertProcessorExtension(processorFile);
|
|
1075
752
|
}
|
|
1076
|
-
/**
|
|
1077
|
-
* Reject a processor path whose extension is not an executable JS module,
|
|
1078
|
-
* the last line of defense against loading an arbitrary file as a worker.
|
|
1079
|
-
*
|
|
1080
|
-
* @param filePath - The filesystem path (or file: URL pathname) to check.
|
|
1081
|
-
* @throws {QueueException} `WORKER_REGISTRATION_FAILED` for a disallowed extension.
|
|
1082
|
-
*/
|
|
1083
753
|
assertProcessorExtension(filePath) {
|
|
1084
754
|
const allowedExtensions = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs"]);
|
|
1085
755
|
if (!allowedExtensions.has(path.extname(filePath))) {
|
|
@@ -1092,23 +762,11 @@ exports.WorkerRegistry = class WorkerRegistry {
|
|
|
1092
762
|
);
|
|
1093
763
|
}
|
|
1094
764
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
* Build a BullMQ `WorkerOptions` object from the library's `WorkerOptions`
|
|
1097
|
-
* shape. Only forwards optional fields that are explicitly set to preserve
|
|
1098
|
-
* `exactOptionalPropertyTypes` correctness. Accepts the pre-created duplicated
|
|
1099
|
-
* connection so callers can close it on construction failure.
|
|
1100
|
-
*
|
|
1101
|
-
* @param opts - Library-level worker options.
|
|
1102
|
-
* @param conn - Pre-created duplicated Redis connection.
|
|
1103
|
-
* @returns A BullMQ-compatible options object.
|
|
1104
|
-
*/
|
|
1105
765
|
buildBullOptions(opts, conn) {
|
|
1106
766
|
const result = {
|
|
1107
767
|
connection: conn,
|
|
1108
768
|
concurrency: opts?.concurrency ?? DEFAULT_WORKER_CONCURRENCY,
|
|
1109
769
|
autorun: opts?.autorun ?? true,
|
|
1110
|
-
// Match the producer Queue's key prefix so the worker polls the same
|
|
1111
|
-
// keyspace; without this a non-default prefix leaves jobs unconsumed.
|
|
1112
770
|
prefix: this.options.prefix
|
|
1113
771
|
};
|
|
1114
772
|
if (opts?.limiter !== void 0) result.limiter = opts.limiter;
|
|
@@ -1135,14 +793,6 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
|
|
|
1135
793
|
}
|
|
1136
794
|
connection;
|
|
1137
795
|
options;
|
|
1138
|
-
/**
|
|
1139
|
-
* Returns the cached `QueueEvents` for `queueName`, creating one on first
|
|
1140
|
-
* call. Subsequent calls for the same name return the same instance without
|
|
1141
|
-
* opening a second Redis connection.
|
|
1142
|
-
*
|
|
1143
|
-
* @param queueName - The BullMQ queue to observe.
|
|
1144
|
-
* @returns The `QueueEvents` instance for the queue.
|
|
1145
|
-
*/
|
|
1146
796
|
getOrCreate(queueName) {
|
|
1147
797
|
const existing = __privateGet(this, _events).get(queueName);
|
|
1148
798
|
if (existing) return existing;
|
|
@@ -1159,35 +809,12 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
|
|
|
1159
809
|
__privateGet(this, _connections).set(queueName, conn);
|
|
1160
810
|
return qe;
|
|
1161
811
|
}
|
|
1162
|
-
/**
|
|
1163
|
-
* Returns the queue names for which a `QueueEvents` connection is open.
|
|
1164
|
-
*
|
|
1165
|
-
* @returns An immutable array of queue names.
|
|
1166
|
-
*/
|
|
1167
812
|
list() {
|
|
1168
813
|
return Array.from(__privateGet(this, _events).keys());
|
|
1169
814
|
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Returns the live `QueueEvents` map. Intended for the shutdown orchestrator.
|
|
1172
|
-
*
|
|
1173
|
-
* @returns A read-only view of the internal map.
|
|
1174
|
-
*/
|
|
1175
815
|
getAll() {
|
|
1176
816
|
return __privateGet(this, _events);
|
|
1177
817
|
}
|
|
1178
|
-
/**
|
|
1179
|
-
* Returns a read-only view of the duplicated connections the library opened
|
|
1180
|
-
* for its `QueueEvents`, keyed by queue name. Intended for the shutdown
|
|
1181
|
-
* orchestrator so it can close exactly the connections the library created
|
|
1182
|
-
* (Mode A never exposes the consumer's shared client here).
|
|
1183
|
-
*
|
|
1184
|
-
* Returns a copy. `ReadonlyMap` is a compile-time claim only — handing back the
|
|
1185
|
-
* live registry lets any consumer cast it away and delete a connection the
|
|
1186
|
-
* shutdown sequence is about to close. The allocation costs nothing on a path
|
|
1187
|
-
* that runs once per shutdown.
|
|
1188
|
-
*
|
|
1189
|
-
* @returns A read-only snapshot of queue name to duplicated connection.
|
|
1190
|
-
*/
|
|
1191
818
|
getConnections() {
|
|
1192
819
|
return new Map(__privateGet(this, _connections));
|
|
1193
820
|
}
|
|
@@ -1207,6 +834,7 @@ var WORKER_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:wo
|
|
|
1207
834
|
var QUEUE_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:queue_event_listeners");
|
|
1208
835
|
|
|
1209
836
|
// src/server/services/processor-discovery.service.ts
|
|
837
|
+
var EVENT_HANDLER_FAILED = "queue.event_handler_failed";
|
|
1210
838
|
var ProcessorDiscoveryService = class {
|
|
1211
839
|
constructor(discovery, workers, events) {
|
|
1212
840
|
this.discovery = discovery;
|
|
@@ -1218,10 +846,6 @@ var ProcessorDiscoveryService = class {
|
|
|
1218
846
|
events;
|
|
1219
847
|
logger = new common.Logger(ProcessorDiscoveryService.name);
|
|
1220
848
|
registeredQueues = /* @__PURE__ */ new Set();
|
|
1221
|
-
/**
|
|
1222
|
-
* Entry point called by NestJS after the module is fully initialized.
|
|
1223
|
-
* Discovers all `@Processor`-annotated providers and wires them.
|
|
1224
|
-
*/
|
|
1225
849
|
onModuleInit() {
|
|
1226
850
|
const providers = this.discovery.getProviders();
|
|
1227
851
|
for (const wrapper of providers) {
|
|
@@ -1233,15 +857,6 @@ var ProcessorDiscoveryService = class {
|
|
|
1233
857
|
this.wireProcessor(instance, processorMeta);
|
|
1234
858
|
}
|
|
1235
859
|
}
|
|
1236
|
-
/**
|
|
1237
|
-
* Build a dispatcher function that routes `job.name` to the correct handler.
|
|
1238
|
-
* Named handlers take priority; a catch-all (`@Process()`) handles everything
|
|
1239
|
-
* else. Throws if no matching handler is found at job-processing time.
|
|
1240
|
-
*
|
|
1241
|
-
* @param instance - The provider instance whose methods serve as handlers.
|
|
1242
|
-
* @param handlers - Accumulated `@Process` metadata entries for the class.
|
|
1243
|
-
* @returns A dispatcher `(job: Job) => Promise<unknown>`.
|
|
1244
|
-
*/
|
|
1245
860
|
buildDispatcher(instance, handlers) {
|
|
1246
861
|
const namedHandlers = /* @__PURE__ */ new Map();
|
|
1247
862
|
let catchAll;
|
|
@@ -1266,13 +881,6 @@ var ProcessorDiscoveryService = class {
|
|
|
1266
881
|
);
|
|
1267
882
|
};
|
|
1268
883
|
}
|
|
1269
|
-
/**
|
|
1270
|
-
* Wire a single discovered processor class: validate uniqueness, register a
|
|
1271
|
-
* Worker, bind worker-local and queue-global event listeners.
|
|
1272
|
-
*
|
|
1273
|
-
* @param instance - The resolved provider instance.
|
|
1274
|
-
* @param processorMeta - Metadata written by `@Processor`.
|
|
1275
|
-
*/
|
|
1276
884
|
wireProcessor(instance, processorMeta) {
|
|
1277
885
|
const { queueName, workerOptions, _warnedNoConcurrency } = processorMeta;
|
|
1278
886
|
const ctor = instance.constructor;
|
|
@@ -1301,7 +909,14 @@ var ProcessorDiscoveryService = class {
|
|
|
1301
909
|
for (const { eventName, methodKey } of workerListeners) {
|
|
1302
910
|
const method = instance[methodKey];
|
|
1303
911
|
if (typeof method === "function") {
|
|
1304
|
-
workerEmitter.on(
|
|
912
|
+
workerEmitter.on(
|
|
913
|
+
eventName,
|
|
914
|
+
this.guardListener(instance, method, {
|
|
915
|
+
decorator: "@OnWorkerEvent",
|
|
916
|
+
eventName,
|
|
917
|
+
methodKey
|
|
918
|
+
})
|
|
919
|
+
);
|
|
1305
920
|
}
|
|
1306
921
|
}
|
|
1307
922
|
const queueListeners = Reflect.getOwnMetadata(QUEUE_EVENT_LISTENERS_METADATA_KEY, ctor) ?? [];
|
|
@@ -1311,11 +926,32 @@ var ProcessorDiscoveryService = class {
|
|
|
1311
926
|
for (const { eventName, methodKey } of queueListeners) {
|
|
1312
927
|
const method = instance[methodKey];
|
|
1313
928
|
if (typeof method === "function") {
|
|
1314
|
-
qeEmitter.on(
|
|
929
|
+
qeEmitter.on(
|
|
930
|
+
eventName,
|
|
931
|
+
this.guardListener(instance, method, {
|
|
932
|
+
decorator: "@OnQueueEvent",
|
|
933
|
+
eventName,
|
|
934
|
+
methodKey
|
|
935
|
+
})
|
|
936
|
+
);
|
|
1315
937
|
}
|
|
1316
938
|
}
|
|
1317
939
|
}
|
|
1318
940
|
}
|
|
941
|
+
guardListener(instance, method, origin) {
|
|
942
|
+
const report = (cause) => {
|
|
943
|
+
this.logger.error(
|
|
944
|
+
`${origin.decorator}("${origin.eventName}") handler ${instance.constructor.name}.${String(origin.methodKey)} failed (${EVENT_HANDLER_FAILED}): ${describeError(cause)}`
|
|
945
|
+
);
|
|
946
|
+
};
|
|
947
|
+
return (...args) => {
|
|
948
|
+
try {
|
|
949
|
+
void Promise.resolve(method.apply(instance, args)).catch(report);
|
|
950
|
+
} catch (cause) {
|
|
951
|
+
report(cause);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
1319
955
|
};
|
|
1320
956
|
ProcessorDiscoveryService = __decorateClass([
|
|
1321
957
|
common.Injectable(),
|
|
@@ -1323,6 +959,8 @@ ProcessorDiscoveryService = __decorateClass([
|
|
|
1323
959
|
__decorateParam(1, common.Inject(exports.WorkerRegistry)),
|
|
1324
960
|
__decorateParam(2, common.Inject(exports.QueueEventsRegistry))
|
|
1325
961
|
], ProcessorDiscoveryService);
|
|
962
|
+
var DrainTimeoutError = class extends Error {
|
|
963
|
+
};
|
|
1326
964
|
var QueueLifecycle = class {
|
|
1327
965
|
constructor(workers, events, queues, flow, connection, resolved) {
|
|
1328
966
|
this.workers = workers;
|
|
@@ -1339,53 +977,39 @@ var QueueLifecycle = class {
|
|
|
1339
977
|
connection;
|
|
1340
978
|
resolved;
|
|
1341
979
|
logger = new common.Logger(QueueLifecycle.name);
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
* context closes (e.g. on `SIGTERM` with shutdown hooks enabled).
|
|
1345
|
-
*/
|
|
980
|
+
startedAt;
|
|
981
|
+
forcedWorkers = 0;
|
|
1346
982
|
async onModuleDestroy() {
|
|
1347
|
-
|
|
1348
|
-
|
|
983
|
+
this.startedAt = Date.now();
|
|
984
|
+
this.forcedWorkers = await this.closeWorkers();
|
|
1349
985
|
await this.closeQueueEvents();
|
|
1350
986
|
await this.closeDuplicatedConnections();
|
|
987
|
+
}
|
|
988
|
+
async onApplicationShutdown() {
|
|
1351
989
|
await this.drainQueuesIfRequested();
|
|
1352
990
|
await this.closeFlowProducer();
|
|
1353
991
|
await this.closeQueues();
|
|
1354
992
|
await this.teardownConnection();
|
|
1355
993
|
this.logger.log(
|
|
1356
|
-
`Queue shutdown complete in ${String(Date.now() -
|
|
994
|
+
`Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (forced ${String(this.forcedWorkers)} worker(s))`
|
|
1357
995
|
);
|
|
1358
996
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
* Close every worker with a bounded drain, force-closing any that exceed the
|
|
1361
|
-
* budget. Returns the number of workers that had to be force-closed.
|
|
1362
|
-
*
|
|
1363
|
-
* @returns The count of force-closed workers.
|
|
1364
|
-
*/
|
|
1365
997
|
async closeWorkers() {
|
|
1366
998
|
let forced = 0;
|
|
1367
999
|
const drainTimeoutMs = this.resolved.shutdown.drainTimeoutMs;
|
|
1368
1000
|
for (const [name, worker] of this.workers.getAll()) {
|
|
1369
1001
|
try {
|
|
1370
1002
|
await this.closeWorkerWithTimeout(worker, drainTimeoutMs);
|
|
1371
|
-
} catch {
|
|
1003
|
+
} catch (cause) {
|
|
1372
1004
|
forced++;
|
|
1373
1005
|
this.logger.warn(
|
|
1374
|
-
`Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried`
|
|
1006
|
+
cause instanceof DrainTimeoutError ? `Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried` : `Worker "${name}" failed to close gracefully (${describeError(cause)}) \u2014 forcing close; the in-flight job becomes stalled and is retried`
|
|
1375
1007
|
);
|
|
1376
1008
|
void worker.close(true).catch(() => void 0);
|
|
1377
1009
|
}
|
|
1378
1010
|
}
|
|
1379
1011
|
return forced;
|
|
1380
1012
|
}
|
|
1381
|
-
/**
|
|
1382
|
-
* Race a graceful `worker.close()` against a timer. `worker.close()` already
|
|
1383
|
-
* drains active jobs and takes no timeout argument, so the timer is the only
|
|
1384
|
-
* way to bound the wait. Rejects with a timeout error when the budget elapses.
|
|
1385
|
-
*
|
|
1386
|
-
* @param worker - The worker to close.
|
|
1387
|
-
* @param timeoutMs - The drain budget in milliseconds.
|
|
1388
|
-
*/
|
|
1389
1013
|
async closeWorkerWithTimeout(worker, timeoutMs) {
|
|
1390
1014
|
let timer;
|
|
1391
1015
|
const closed = worker.close();
|
|
@@ -1395,7 +1019,7 @@ var QueueLifecycle = class {
|
|
|
1395
1019
|
closed,
|
|
1396
1020
|
new Promise((_resolve, reject) => {
|
|
1397
1021
|
timer = setTimeout(() => {
|
|
1398
|
-
reject(new
|
|
1022
|
+
reject(new DrainTimeoutError());
|
|
1399
1023
|
}, timeoutMs);
|
|
1400
1024
|
})
|
|
1401
1025
|
]);
|
|
@@ -1403,18 +1027,11 @@ var QueueLifecycle = class {
|
|
|
1403
1027
|
clearTimeout(timer);
|
|
1404
1028
|
}
|
|
1405
1029
|
}
|
|
1406
|
-
/** Close every cached `QueueEvents`, swallowing individual failures. */
|
|
1407
1030
|
async closeQueueEvents() {
|
|
1408
1031
|
for (const [, queueEvents] of this.events.getAll()) {
|
|
1409
1032
|
await queueEvents.close().catch(() => void 0);
|
|
1410
1033
|
}
|
|
1411
1034
|
}
|
|
1412
|
-
/**
|
|
1413
|
-
* Close the duplicated worker/queue-events connections the library opened.
|
|
1414
|
-
* BullMQ treats a passed-in client as shared and never closes it, so the
|
|
1415
|
-
* library must release these explicitly. In Mode A the consumer's shared
|
|
1416
|
-
* client is never among them.
|
|
1417
|
-
*/
|
|
1418
1035
|
async closeDuplicatedConnections() {
|
|
1419
1036
|
const connections = [
|
|
1420
1037
|
...this.workers.getConnections().values(),
|
|
@@ -1428,28 +1045,22 @@ var QueueLifecycle = class {
|
|
|
1428
1045
|
}
|
|
1429
1046
|
}
|
|
1430
1047
|
}
|
|
1431
|
-
/**
|
|
1432
|
-
* Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
|
|
1433
|
-
* Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
|
|
1434
|
-
*/
|
|
1435
1048
|
async drainQueuesIfRequested() {
|
|
1436
1049
|
if (!this.resolved.shutdown.drainOnShutdown) return;
|
|
1437
|
-
for (const [, queue] of this.queues.getCachedQueues()) {
|
|
1438
|
-
await queue.drain().catch(() =>
|
|
1050
|
+
for (const [name, queue] of this.queues.getCachedQueues()) {
|
|
1051
|
+
await queue.drain().catch((cause) => {
|
|
1052
|
+
this.logger.warn(
|
|
1053
|
+
`Queue "${name}" could not be drained on shutdown (drainOnShutdown): ${describeError(cause)}; its waiting jobs remain`
|
|
1054
|
+
);
|
|
1055
|
+
});
|
|
1439
1056
|
}
|
|
1440
1057
|
}
|
|
1441
|
-
/** Close the flow producer (idempotent), swallowing any failure. */
|
|
1442
1058
|
async closeFlowProducer() {
|
|
1443
1059
|
await this.flow.close().catch(() => void 0);
|
|
1444
1060
|
}
|
|
1445
|
-
/** Close every cached queue and clear the cache, swallowing individual failures. */
|
|
1446
1061
|
async closeQueues() {
|
|
1447
1062
|
await this.queues.closeAll().catch(() => void 0);
|
|
1448
1063
|
}
|
|
1449
|
-
/**
|
|
1450
|
-
* Tear down the Queue-role connection. Mode B quits the library-owned client;
|
|
1451
|
-
* Mode A is a no-op for the consumer's shared client.
|
|
1452
|
-
*/
|
|
1453
1064
|
async teardownConnection() {
|
|
1454
1065
|
await this.connection.teardown().catch(() => void 0);
|
|
1455
1066
|
}
|
|
@@ -1466,24 +1077,12 @@ QueueLifecycle = __decorateClass([
|
|
|
1466
1077
|
|
|
1467
1078
|
// src/server/bymax-queue.module.ts
|
|
1468
1079
|
var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
|
|
1469
|
-
// The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
|
|
1470
|
-
// which the module spec asserts by value; emptying the name would degrade it to an opaque
|
|
1471
|
-
// `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
|
|
1472
1080
|
new common.ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
|
|
1473
1081
|
...definition,
|
|
1474
1082
|
global: extras.isGlobal
|
|
1475
1083
|
})).build()
|
|
1476
1084
|
);
|
|
1477
1085
|
exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClass {
|
|
1478
|
-
/**
|
|
1479
|
-
* Synchronous registration. Validates options, resolves defaults, and wires the
|
|
1480
|
-
* connection resolver (initialized via async factory), the queue service, the
|
|
1481
|
-
* worker registry, the queue-events registry, processor discovery, and the
|
|
1482
|
-
* graceful-shutdown lifecycle.
|
|
1483
|
-
*
|
|
1484
|
-
* @param options - Static module options.
|
|
1485
|
-
* @returns The configured dynamic module.
|
|
1486
|
-
*/
|
|
1487
1086
|
static forRoot(options) {
|
|
1488
1087
|
validateOptions(options);
|
|
1489
1088
|
const resolved = applyDefaults(options);
|
|
@@ -1509,17 +1108,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
|
|
|
1509
1108
|
exports: exports.BymaxQueueModule.buildExports()
|
|
1510
1109
|
};
|
|
1511
1110
|
}
|
|
1512
|
-
/**
|
|
1513
|
-
* Asynchronous registration. Use when options depend on other modules
|
|
1514
|
-
* (ConfigService, BymaxCacheModule, etc.). Mirrors the NestJS standard async
|
|
1515
|
-
* dynamic-module pattern (`useFactory` | `useClass` | `useExisting` + `inject`)
|
|
1516
|
-
* and registers the same provider graph and exports as {@link forRoot}, with
|
|
1517
|
-
* `global` applied from the `isGlobal` extra. The resolved options and the
|
|
1518
|
-
* initialized connection are derived from the async-resolved options at runtime.
|
|
1519
|
-
*
|
|
1520
|
-
* @param options - Asynchronous module options.
|
|
1521
|
-
* @returns The configured dynamic module.
|
|
1522
|
-
*/
|
|
1523
1111
|
static forRootAsync(options) {
|
|
1524
1112
|
const base = super.forRootAsync(options);
|
|
1525
1113
|
return {
|
|
@@ -1550,14 +1138,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
|
|
|
1550
1138
|
exports: exports.BymaxQueueModule.buildExports()
|
|
1551
1139
|
};
|
|
1552
1140
|
}
|
|
1553
|
-
/**
|
|
1554
|
-
* Providers shared verbatim by both registration paths: the options alias, the
|
|
1555
|
-
* initialized connection resolver, the resolved client/mode tokens, and the
|
|
1556
|
-
* singleton services. `BYMAX_QUEUE_RESOLVED_OPTIONS`, `FlowService`, and
|
|
1557
|
-
* `MetricsService` are intentionally omitted — they differ per path.
|
|
1558
|
-
*
|
|
1559
|
-
* @returns The shared provider list.
|
|
1560
|
-
*/
|
|
1561
1141
|
static buildCoreProviders() {
|
|
1562
1142
|
return [
|
|
1563
1143
|
{ provide: BYMAX_QUEUE_OPTIONS, useExisting: MODULE_OPTIONS_TOKEN },
|
|
@@ -1587,11 +1167,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
|
|
|
1587
1167
|
QueueLifecycle
|
|
1588
1168
|
];
|
|
1589
1169
|
}
|
|
1590
|
-
/**
|
|
1591
|
-
* The public exports shared by both registration paths.
|
|
1592
|
-
*
|
|
1593
|
-
* @returns A fresh exports list (services and injection tokens).
|
|
1594
|
-
*/
|
|
1595
1170
|
static buildExports() {
|
|
1596
1171
|
return [
|
|
1597
1172
|
exports.QueueService,
|