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