@bymax-one/nest-queue 1.2.13 → 1.2.14

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.
@@ -76,6 +76,12 @@ var QueueException = class extends HttpException {
76
76
  }
77
77
  };
78
78
 
79
+ // src/server/utils/timer-delay.ts
80
+ var MAX_TIMER_DELAY_MS = 2147483647;
81
+ function isTimerDelayMs(value) {
82
+ return Number.isInteger(value) && value >= 1 && value <= MAX_TIMER_DELAY_MS;
83
+ }
84
+
79
85
  // src/server/config/validate-options.ts
80
86
  function invalid(reason) {
81
87
  throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, HttpStatus.INTERNAL_SERVER_ERROR, {
@@ -126,6 +132,12 @@ function validateOptions(opts) {
126
132
  if (cacheTtlMs !== void 0 && cacheTtlMs < 0) {
127
133
  invalid("metrics.cacheTtlMs must be >= 0");
128
134
  }
135
+ for (const key of ["commandTimeoutMs", "enqueueTimeoutMs"]) {
136
+ const value = opts[key];
137
+ if (value !== void 0 && !isTimerDelayMs(value)) {
138
+ invalid(`${key} must be a whole number from 1 to ${String(MAX_TIMER_DELAY_MS)}`);
139
+ }
140
+ }
129
141
  }
130
142
 
131
143
  // src/server/constants/default-options.ts
@@ -133,11 +145,14 @@ var DEFAULT_WORKER_CONCURRENCY = 2;
133
145
  var MAX_WORKER_CONCURRENCY = 1e3;
134
146
  var DEFAULT_JOB_OPTIONS = {
135
147
  attempts: 3,
136
- backoff: { type: "exponential", delay: 2e3 },
148
+ backoff: { type: "exponential", delay: 2e3, jitter: 0.5 },
137
149
  removeOnComplete: { age: 24 * 3600, count: 1e3 },
138
150
  removeOnFail: { age: 7 * 24 * 3600, count: 5e3 }
139
151
  };
140
152
  var DEFAULT_CONNECTION_READY_TIMEOUT_MS = 1e4;
153
+ var DEFAULT_COMMAND_TIMEOUT_MS = 5e3;
154
+ var DEFAULT_ENQUEUE_TIMEOUT_MS = 1e4;
155
+ var MAX_JOBS_PAGE_SIZE = 1e3;
141
156
  var DEFAULT_METRICS_CACHE_TTL_MS = 5e3;
142
157
  var DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 3e4;
143
158
 
@@ -157,7 +172,9 @@ function applyDefaults(opts) {
157
172
  drainTimeoutMs: opts.shutdown?.drainTimeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS,
158
173
  drainOnShutdown: opts.shutdown?.drainOnShutdown ?? false
159
174
  },
160
- connectionReadyTimeoutMs: opts.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
175
+ connectionReadyTimeoutMs: opts.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS,
176
+ commandTimeoutMs: opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
177
+ enqueueTimeoutMs: opts.enqueueTimeoutMs ?? DEFAULT_ENQUEUE_TIMEOUT_MS
161
178
  };
162
179
  if (opts.telemetry !== void 0) base.telemetry = opts.telemetry;
163
180
  Object.defineProperty(base, "connection", {
@@ -222,7 +239,12 @@ function attachClientErrorListener(client, logger, role) {
222
239
 
223
240
  // src/server/utils/duplicate-connection.ts
224
241
  function duplicateConnection(client) {
225
- return client.duplicate({ maxRetriesPerRequest: null });
242
+ const override = {
243
+ maxRetriesPerRequest: null,
244
+ enableOfflineQueue: true,
245
+ commandTimeout: void 0
246
+ };
247
+ return client.duplicate(override);
226
248
  }
227
249
 
228
250
  // src/server/services/connection-resolver.service.ts
@@ -244,12 +266,22 @@ var ConnectionResolver = class {
244
266
  this.mode = "mode-b-owned";
245
267
  __privateSet(this, _client, "url" in cfg ? new Redis(cfg.url, this.ownedOptions(cfg.options)) : new Redis(this.ownedOptions(cfg.options)));
246
268
  attachClientErrorListener(__privateGet(this, _client), __privateGet(this, _logger), "owned");
247
- await this.waitReady(
248
- __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
249
- );
269
+ try {
270
+ await this.waitReady(
271
+ __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
272
+ );
273
+ } catch (cause) {
274
+ __privateGet(this, _client).disconnect();
275
+ throw cause;
276
+ }
250
277
  }
251
278
  ownedOptions(options) {
252
- return { ...options ?? {}, lazyConnect: false };
279
+ return {
280
+ commandTimeout: __privateGet(this, _options).commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
281
+ enableOfflineQueue: false,
282
+ ...options ?? {},
283
+ lazyConnect: false
284
+ };
253
285
  }
254
286
  getClient() {
255
287
  if (!__privateGet(this, _client)) {
@@ -296,6 +328,11 @@ var ConnectionResolver = class {
296
328
  { status: client.status }
297
329
  );
298
330
  }
331
+ if (client.options.maxRetriesPerRequest === null) {
332
+ __privateGet(this, _logger).warn(
333
+ "The shared client has maxRetriesPerRequest: null, so a producer call made while Redis is unreachable waits for as long as the outage lasts; enqueue is bounded by enqueueTimeoutMs, other QueueService calls are not. Give the client a finite limit to fail fast."
334
+ );
335
+ }
299
336
  const probe = duplicateConnection(client);
300
337
  attachClientErrorListener(probe, __privateGet(this, _logger), "probe");
301
338
  try {
@@ -308,31 +345,35 @@ var ConnectionResolver = class {
308
345
  const client = this.getClient();
309
346
  if (client.status === "ready") return;
310
347
  await new Promise((resolve, reject) => {
348
+ let lastErrorCode;
349
+ const details = (extra) => lastErrorCode === void 0 ? extra : { ...extra, lastErrorCode };
311
350
  const cleanup = () => {
312
351
  clearTimeout(timer);
313
352
  client.off("ready", onReady);
314
353
  client.off("error", onError);
354
+ client.off("end", onEnd);
355
+ };
356
+ const fail = (code, extra) => {
357
+ cleanup();
358
+ reject(new QueueException(code, HttpStatus.INTERNAL_SERVER_ERROR, details(extra)));
315
359
  };
316
360
  const onReady = () => {
317
361
  cleanup();
318
362
  resolve();
319
363
  };
320
364
  const onError = (err) => {
321
- cleanup();
322
- reject(err);
365
+ lastErrorCode = startupErrorCode(err);
366
+ if (isReplyError(err)) fail(QUEUE_ERROR_CODES.CONNECTION_INVALID, {});
367
+ };
368
+ const onEnd = () => {
369
+ fail(QUEUE_ERROR_CODES.CONNECTION_INVALID, {});
323
370
  };
324
371
  const timer = setTimeout(() => {
325
- cleanup();
326
- reject(
327
- new QueueException(
328
- QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
329
- HttpStatus.INTERNAL_SERVER_ERROR,
330
- { timeoutMs }
331
- )
332
- );
372
+ fail(QUEUE_ERROR_CODES.CONNECTION_TIMEOUT, { timeoutMs });
333
373
  }, timeoutMs);
334
374
  client.once("ready", onReady);
335
- client.once("error", onError);
375
+ client.on("error", onError);
376
+ client.once("end", onEnd);
336
377
  });
337
378
  }
338
379
  };
@@ -343,6 +384,16 @@ ConnectionResolver = __decorateClass([
343
384
  Injectable(),
344
385
  __decorateParam(0, Inject(BYMAX_QUEUE_OPTIONS))
345
386
  ], ConnectionResolver);
387
+ function isReplyError(err) {
388
+ return typeof err === "object" && err !== null && err.name === "ReplyError";
389
+ }
390
+ function startupErrorCode(err) {
391
+ if (typeof err !== "object" || err === null) return void 0;
392
+ const { code, message } = err;
393
+ if (typeof code === "string") return code;
394
+ if (isReplyError(err) && typeof message === "string") return /^[A-Z][A-Z0-9_]*/.exec(message)?.[0];
395
+ return void 0;
396
+ }
346
397
  function invalid2(reason) {
347
398
  throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
348
399
  reason
@@ -382,6 +433,38 @@ function attachDefaultErrorListener(emitter, logger, kind, queueName) {
382
433
  });
383
434
  }
384
435
 
436
+ // src/server/utils/with-deadline.ts
437
+ function withDeadline(work, timeoutMs, onTimeout) {
438
+ let timer;
439
+ const deadline = new Promise((_resolve, reject) => {
440
+ timer = setTimeout(() => {
441
+ reject(onTimeout());
442
+ }, timeoutMs);
443
+ });
444
+ return Promise.race([work, deadline]).finally(() => {
445
+ clearTimeout(timer);
446
+ });
447
+ }
448
+ function timeoutException(operation, timeoutMs, cause) {
449
+ const exception = new QueueException(
450
+ QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
451
+ HttpStatus.INTERNAL_SERVER_ERROR,
452
+ { operation, timeoutMs }
453
+ );
454
+ if (cause !== void 0) exception.cause = cause;
455
+ return exception;
456
+ }
457
+ function isCommandTimeout(err) {
458
+ return err instanceof Error && err.message === "Command timed out";
459
+ }
460
+ async function withCommandTimeoutCode(operation, commandTimeoutMs, work) {
461
+ try {
462
+ return await work;
463
+ } catch (err) {
464
+ throw isCommandTimeout(err) ? timeoutException(operation, commandTimeoutMs(), err) : err;
465
+ }
466
+ }
467
+
385
468
  // src/server/services/queue.service.ts
386
469
  var MAX_BULK_SIZE = 1e3;
387
470
  function assertValidCron(repeat, queueOptions) {
@@ -419,11 +502,28 @@ var QueueService = class {
419
502
  __privateGet(this, _queues).set(queueName, queue);
420
503
  return queue;
421
504
  }
505
+ enqueueTimeout(operation) {
506
+ return timeoutException(operation, this.options.enqueueTimeoutMs);
507
+ }
508
+ call(operation, work) {
509
+ return withCommandTimeoutCode(
510
+ operation,
511
+ () => this.connection.getClient().options.commandTimeout ?? this.options.commandTimeoutMs,
512
+ work
513
+ );
514
+ }
422
515
  queueFor(queueName) {
423
516
  return this.getOrCreateQueue(queueName);
424
517
  }
425
518
  async enqueue(queueName, jobName, data, options) {
426
- const job = await this.queueFor(queueName).add(jobName, data, options);
519
+ const job = await this.call(
520
+ "enqueue",
521
+ withDeadline(
522
+ this.queueFor(queueName).add(jobName, data, options),
523
+ this.options.enqueueTimeoutMs,
524
+ () => this.enqueueTimeout("enqueue")
525
+ )
526
+ );
427
527
  return job;
428
528
  }
429
529
  async enqueueBulk(queueName, jobs) {
@@ -439,15 +539,25 @@ var QueueService = class {
439
539
  );
440
540
  }
441
541
  try {
442
- const created = await this.queueFor(queueName).addBulk(
443
- jobs.map((job) => ({
444
- name: job.name,
445
- data: job.data,
446
- ...job.options === void 0 ? {} : { opts: job.options }
447
- }))
542
+ const created = await this.call(
543
+ "enqueueBulk",
544
+ withDeadline(
545
+ this.queueFor(queueName).addBulk(
546
+ jobs.map((job) => ({
547
+ name: job.name,
548
+ data: job.data,
549
+ ...job.options === void 0 ? {} : { opts: job.options }
550
+ }))
551
+ ),
552
+ this.options.enqueueTimeoutMs,
553
+ () => this.enqueueTimeout("enqueueBulk")
554
+ )
448
555
  );
449
556
  return created;
450
557
  } catch (err) {
558
+ if (err instanceof QueueException && errorCodeOf(err) === QUEUE_ERROR_CODES.CONNECTION_TIMEOUT) {
559
+ throw err;
560
+ }
451
561
  throw new QueueException(
452
562
  QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
453
563
  HttpStatus.INTERNAL_SERVER_ERROR,
@@ -458,20 +568,22 @@ var QueueService = class {
458
568
  }
459
569
  }
460
570
  async getJob(queueName, jobId) {
461
- const job = await this.queueFor(queueName).getJob(jobId);
571
+ const job = await this.call("getJob", this.queueFor(queueName).getJob(jobId));
462
572
  return job ?? null;
463
573
  }
464
574
  async getJobs(queueName, status, start = 0, end = 50) {
465
- const jobs = await this.queueFor(queueName).getJobs([status], start, end);
575
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end - start >= MAX_JOBS_PAGE_SIZE) {
576
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, HttpStatus.BAD_REQUEST, {
577
+ reason: `getJobs needs integers with 0 <= start <= end and at most ${String(MAX_JOBS_PAGE_SIZE)} jobs per call`
578
+ });
579
+ }
580
+ const jobs = await this.call("getJobs", this.queueFor(queueName).getJobs([status], start, end));
466
581
  return jobs;
467
582
  }
468
583
  async getMetrics(queueName) {
469
- const counts = await this.queueFor(queueName).getJobCounts(
470
- "waiting",
471
- "active",
472
- "completed",
473
- "failed",
474
- "delayed"
584
+ const counts = await this.call(
585
+ "getMetrics",
586
+ this.queueFor(queueName).getJobCounts("waiting", "active", "completed", "failed", "delayed")
475
587
  );
476
588
  return {
477
589
  queue: queueName,
@@ -488,23 +600,26 @@ var QueueService = class {
488
600
  ...template?.opts === void 0 ? {} : { opts: template.opts }
489
601
  };
490
602
  if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
491
- const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
603
+ const job = await this.call(
604
+ "upsertJobScheduler",
605
+ queue.upsertJobScheduler(schedulerId, repeat, jobTemplate)
606
+ );
492
607
  return job;
493
608
  }
494
609
  async removeJobScheduler(queueName, schedulerId) {
495
- return this.queueFor(queueName).removeJobScheduler(schedulerId);
610
+ return this.call("removeJobScheduler", this.queueFor(queueName).removeJobScheduler(schedulerId));
496
611
  }
497
612
  async getJobSchedulers(queueName, start = 0, end = 50, asc = true) {
498
- return this.queueFor(queueName).getJobSchedulers(start, end, asc);
613
+ return this.call("getJobSchedulers", this.queueFor(queueName).getJobSchedulers(start, end, asc));
499
614
  }
500
615
  async pauseQueue(queueName) {
501
- await this.queueFor(queueName).pause();
616
+ await this.call("pauseQueue", this.queueFor(queueName).pause());
502
617
  }
503
618
  async resumeQueue(queueName) {
504
- await this.queueFor(queueName).resume();
619
+ await this.call("resumeQueue", this.queueFor(queueName).resume());
505
620
  }
506
621
  async cleanQueue(queueName, gracePeriodMs, limit, status) {
507
- return this.queueFor(queueName).clean(gracePeriodMs, limit, status);
622
+ return this.call("cleanQueue", this.queueFor(queueName).clean(gracePeriodMs, limit, status));
508
623
  }
509
624
  getCachedQueues() {
510
625
  return __privateGet(this, _queues);
@@ -522,10 +637,16 @@ QueueService = __decorateClass([
522
637
  __decorateParam(0, Inject(ConnectionResolver)),
523
638
  __decorateParam(1, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
524
639
  ], QueueService);
640
+ function errorCodeOf(err) {
641
+ return err.getResponse().error.code;
642
+ }
643
+ var _connection;
525
644
  var FlowService = class {
526
- logger = new Logger(FlowService.name);
527
- producer;
528
645
  constructor(connection, enabled, prefix, telemetry) {
646
+ __publicField(this, "logger", new Logger(FlowService.name));
647
+ __publicField(this, "producer");
648
+ __privateAdd(this, _connection);
649
+ __privateSet(this, _connection, connection);
529
650
  if (enabled) {
530
651
  this.producer = new FlowProducer({
531
652
  connection: connection.getClient(),
@@ -536,10 +657,21 @@ var FlowService = class {
536
657
  }
537
658
  }
538
659
  async add(flow) {
539
- return this.requireProducer().add(flow);
660
+ return withCommandTimeoutCode(
661
+ "addFlow",
662
+ () => this.commandTimeout(),
663
+ this.requireProducer().add(flow)
664
+ );
540
665
  }
541
666
  async addBulk(flows) {
542
- return this.requireProducer().addBulk([...flows]);
667
+ return withCommandTimeoutCode(
668
+ "addFlowBulk",
669
+ () => this.commandTimeout(),
670
+ this.requireProducer().addBulk([...flows])
671
+ );
672
+ }
673
+ commandTimeout() {
674
+ return __privateGet(this, _connection).getClient().options.commandTimeout ?? 0;
543
675
  }
544
676
  getProducer() {
545
677
  return this.requireProducer();
@@ -556,6 +688,7 @@ var FlowService = class {
556
688
  return this.producer;
557
689
  }
558
690
  };
691
+ _connection = new WeakMap();
559
692
  FlowService = __decorateClass([
560
693
  Injectable()
561
694
  ], FlowService);
@@ -619,7 +752,11 @@ var WorkerRegistry = class {
619
752
  const bullOpts = this.buildBullOptions(options, conn);
620
753
  let worker;
621
754
  try {
622
- worker = new Worker(queueName, handler, bullOpts);
755
+ worker = new Worker(
756
+ queueName,
757
+ toProcessor(handler, options?.jobTimeoutMs),
758
+ bullOpts
759
+ );
623
760
  } catch (cause) {
624
761
  conn.disconnect();
625
762
  throw new QueueException(
@@ -637,6 +774,13 @@ var WorkerRegistry = class {
637
774
  }
638
775
  registerSandboxed(config) {
639
776
  const { queueName, processorFile, options } = config;
777
+ if (options?.jobTimeoutMs !== void 0) {
778
+ throw new QueueException(
779
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
780
+ HttpStatus.INTERNAL_SERVER_ERROR,
781
+ { reason: "jobTimeoutMs is not available for sandboxed workers" }
782
+ );
783
+ }
640
784
  this.validateProcessorFile(processorFile);
641
785
  this.guardDuplicate(queueName);
642
786
  this.validateOptions(options);
@@ -666,9 +810,12 @@ var WorkerRegistry = class {
666
810
  async unregister(queueName) {
667
811
  const entry = __privateGet(this, _entries).get(queueName);
668
812
  if (!entry) return;
669
- await entry.worker.close();
670
- await this.closeConnection(entry.connection);
671
- __privateGet(this, _entries).delete(queueName);
813
+ try {
814
+ await entry.worker.close();
815
+ } finally {
816
+ await this.closeConnection(entry.connection);
817
+ __privateGet(this, _entries).delete(queueName);
818
+ }
672
819
  }
673
820
  list() {
674
821
  return Array.from(__privateGet(this, _entries).keys());
@@ -722,6 +869,7 @@ var WorkerRegistry = class {
722
869
  );
723
870
  }
724
871
  }
872
+ this.validateJobTimeout(opts);
725
873
  if (opts?.limiter !== void 0) {
726
874
  if (opts.limiter.max < 1) {
727
875
  throw new QueueException(
@@ -745,6 +893,18 @@ var WorkerRegistry = class {
745
893
  }
746
894
  }
747
895
  }
896
+ validateJobTimeout(opts) {
897
+ if (opts?.jobTimeoutMs !== void 0 && !isTimerDelayMs(opts.jobTimeoutMs)) {
898
+ throw new QueueException(
899
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
900
+ HttpStatus.INTERNAL_SERVER_ERROR,
901
+ {
902
+ reason: "jobTimeoutMs must be a whole number from 1 to 2147483647",
903
+ actual: opts.jobTimeoutMs
904
+ }
905
+ );
906
+ }
907
+ }
748
908
  validateProcessorFile(processorFile) {
749
909
  if (processorFile instanceof URL) {
750
910
  if (processorFile.protocol !== "file:") {
@@ -802,6 +962,34 @@ WorkerRegistry = __decorateClass([
802
962
  __decorateParam(0, Inject(ConnectionResolver)),
803
963
  __decorateParam(1, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
804
964
  ], WorkerRegistry);
965
+ var JobTimeoutError = class extends Error {
966
+ constructor(timeoutMs) {
967
+ super(`queue.job_timeout: the handler did not settle within ${String(timeoutMs)}ms`);
968
+ this.name = "JobTimeoutError";
969
+ }
970
+ };
971
+ function toProcessor(handler, timeoutMs) {
972
+ if (timeoutMs === void 0) return (job, _token, signal) => handler(job, signal);
973
+ return (job, _token, signal) => {
974
+ const own = new AbortController();
975
+ const relay = () => {
976
+ own.abort(signal?.reason);
977
+ };
978
+ if (signal?.aborted === true) relay();
979
+ signal?.addEventListener("abort", relay);
980
+ return withDeadline(
981
+ Promise.resolve().then(() => handler(job, own.signal)),
982
+ timeoutMs,
983
+ () => {
984
+ const error = new JobTimeoutError(timeoutMs);
985
+ own.abort(error);
986
+ return error;
987
+ }
988
+ ).finally(() => {
989
+ signal?.removeEventListener("abort", relay);
990
+ });
991
+ };
992
+ }
805
993
  var _events, _connections;
806
994
  var QueueEventsRegistry = class {
807
995
  constructor(connection, options) {
@@ -891,10 +1079,10 @@ var ProcessorDiscoveryService = class {
891
1079
  catchAll = fn;
892
1080
  }
893
1081
  }
894
- return (job) => {
1082
+ return (job, signal) => {
895
1083
  const specific = namedHandlers.get(job.name);
896
- if (specific) return specific(job);
897
- if (catchAll) return catchAll(job);
1084
+ if (specific) return specific(job, signal);
1085
+ if (catchAll) return catchAll(job, signal);
898
1086
  return Promise.reject(
899
1087
  new Error(
900
1088
  `No handler registered for job name "${job.name}" on queue \u2014 add a matching @Process("name") or a catch-all @Process() method.`
@@ -1002,9 +1190,10 @@ var QueueLifecycle = class {
1002
1190
  forcedWorkers = 0;
1003
1191
  async onModuleDestroy() {
1004
1192
  this.startedAt = Date.now();
1005
- this.forcedWorkers = await this.closeWorkers();
1006
- await this.closeQueueEvents();
1007
- await this.closeDuplicatedConnections();
1193
+ const deadline = this.startedAt + this.resolved.shutdown.drainTimeoutMs;
1194
+ this.forcedWorkers = await this.closeWorkers(deadline);
1195
+ await this.closeQueueEvents(deadline);
1196
+ await this.closeDuplicatedConnections(deadline);
1008
1197
  }
1009
1198
  async onApplicationShutdown() {
1010
1199
  await this.drainQueuesIfRequested();
@@ -1015,56 +1204,42 @@ var QueueLifecycle = class {
1015
1204
  `Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (forced ${String(this.forcedWorkers)} worker(s))`
1016
1205
  );
1017
1206
  }
1018
- async closeWorkers() {
1207
+ async closeWorkers(deadline) {
1019
1208
  let forced = 0;
1020
- const drainTimeoutMs = this.resolved.shutdown.drainTimeoutMs;
1021
- for (const [name, worker] of this.workers.getAll()) {
1022
- try {
1023
- await this.closeWorkerWithTimeout(worker, drainTimeoutMs);
1024
- } catch (cause) {
1025
- forced++;
1026
- this.logger.warn(
1027
- 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`
1028
- );
1029
- void worker.close(true).catch(() => void 0);
1030
- }
1031
- }
1209
+ await Promise.all(
1210
+ Array.from(this.workers.getAll(), async ([name, worker]) => {
1211
+ try {
1212
+ await bounded(worker.close(), deadline);
1213
+ } catch (cause) {
1214
+ forced++;
1215
+ this.logger.warn(
1216
+ cause instanceof DrainTimeoutError ? `Worker "${name}" exceeded the ${String(this.resolved.shutdown.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`
1217
+ );
1218
+ void worker.close(true).catch(() => void 0);
1219
+ }
1220
+ })
1221
+ );
1032
1222
  return forced;
1033
1223
  }
1034
- async closeWorkerWithTimeout(worker, timeoutMs) {
1035
- let timer;
1036
- const closed = worker.close();
1037
- closed.catch(() => void 0);
1038
- try {
1039
- await Promise.race([
1040
- closed,
1041
- new Promise((_resolve, reject) => {
1042
- timer = setTimeout(() => {
1043
- reject(new DrainTimeoutError());
1044
- }, timeoutMs);
1045
- })
1046
- ]);
1047
- } finally {
1048
- clearTimeout(timer);
1049
- }
1050
- }
1051
- async closeQueueEvents() {
1052
- for (const [, queueEvents] of this.events.getAll()) {
1053
- await queueEvents.close().catch(() => void 0);
1054
- }
1224
+ async closeQueueEvents(deadline) {
1225
+ await Promise.all(
1226
+ Array.from(this.events.getAll().values(), async (queueEvents) => {
1227
+ await bounded(queueEvents.close(), deadline).catch(() => void 0);
1228
+ })
1229
+ );
1055
1230
  }
1056
- async closeDuplicatedConnections() {
1231
+ async closeDuplicatedConnections(deadline) {
1057
1232
  const connections = [
1058
1233
  ...this.workers.getConnections().values(),
1059
1234
  ...this.events.getConnections().values()
1060
1235
  ];
1061
- for (const connection of connections) {
1062
- try {
1063
- await connection.quit();
1064
- } catch {
1065
- connection.disconnect();
1066
- }
1067
- }
1236
+ await Promise.all(
1237
+ connections.map(async (connection) => {
1238
+ await bounded(connection.quit(), deadline).catch(() => {
1239
+ connection.disconnect();
1240
+ });
1241
+ })
1242
+ );
1068
1243
  }
1069
1244
  async drainQueuesIfRequested() {
1070
1245
  if (!this.resolved.shutdown.drainOnShutdown) return;
@@ -1095,6 +1270,9 @@ QueueLifecycle = __decorateClass([
1095
1270
  __decorateParam(4, Inject(ConnectionResolver)),
1096
1271
  __decorateParam(5, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
1097
1272
  ], QueueLifecycle);
1273
+ function bounded(work, deadline) {
1274
+ return withDeadline(work, Math.max(0, deadline - Date.now()), () => new DrainTimeoutError());
1275
+ }
1098
1276
 
1099
1277
  // src/server/bymax-queue.module.ts
1100
1278
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.2.13",
3
+ "version": "1.2.14",
4
4
  "description": "NestJS dynamic module wrapping BullMQ — typed jobs, flows, job schedulers, deduplication, OpenTelemetry, graceful shutdown",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",