@bymax-one/nest-queue 1.2.13 → 1.2.15

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,17 +239,23 @@ 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
229
- var _logger, _client, _options;
251
+ var _logger, _client, _options, _pendingReady;
230
252
  var ConnectionResolver = class {
231
253
  constructor(options) {
232
254
  __privateAdd(this, _logger, new Logger(ConnectionResolver.name));
233
255
  __privateAdd(this, _client);
234
256
  __privateAdd(this, _options);
235
257
  __publicField(this, "mode");
258
+ __privateAdd(this, _pendingReady);
236
259
  __privateSet(this, _options, options);
237
260
  }
238
261
  async init() {
@@ -244,12 +267,22 @@ var ConnectionResolver = class {
244
267
  this.mode = "mode-b-owned";
245
268
  __privateSet(this, _client, "url" in cfg ? new Redis(cfg.url, this.ownedOptions(cfg.options)) : new Redis(this.ownedOptions(cfg.options)));
246
269
  attachClientErrorListener(__privateGet(this, _client), __privateGet(this, _logger), "owned");
247
- await this.waitReady(
248
- __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
249
- );
270
+ try {
271
+ await this.waitReady(
272
+ __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
273
+ );
274
+ } catch (cause) {
275
+ __privateGet(this, _client).disconnect();
276
+ throw cause;
277
+ }
250
278
  }
251
279
  ownedOptions(options) {
252
- return { ...options ?? {}, lazyConnect: false };
280
+ return {
281
+ commandTimeout: __privateGet(this, _options).commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
282
+ enableOfflineQueue: false,
283
+ ...options ?? {},
284
+ lazyConnect: false
285
+ };
253
286
  }
254
287
  getClient() {
255
288
  if (!__privateGet(this, _client)) {
@@ -278,6 +311,17 @@ var ConnectionResolver = class {
278
311
  isOwned() {
279
312
  return this.mode === "mode-b-owned";
280
313
  }
314
+ awaitReady() {
315
+ const client = this.getClient();
316
+ if (!WAITING_STATUSES.has(client.status)) return Promise.resolve();
317
+ __privateGet(this, _pendingReady) ?? __privateSet(this, _pendingReady, new Promise((resolve) => {
318
+ client.once("ready", () => {
319
+ __privateSet(this, _pendingReady, void 0);
320
+ resolve();
321
+ });
322
+ }));
323
+ return __privateGet(this, _pendingReady);
324
+ }
281
325
  async teardown() {
282
326
  if (this.isOwned() && __privateGet(this, _client)) {
283
327
  const client = __privateGet(this, _client);
@@ -296,6 +340,11 @@ var ConnectionResolver = class {
296
340
  { status: client.status }
297
341
  );
298
342
  }
343
+ if (client.options.maxRetriesPerRequest === null) {
344
+ __privateGet(this, _logger).warn(
345
+ "The shared client has maxRetriesPerRequest: null, so a command issued while Redis is unreachable stays queued in the client for as long as the outage lasts. QueueService calls are bounded by enqueueTimeoutMs and the caller is released, but the command is not cancelled. Give the client a finite limit to fail fast."
346
+ );
347
+ }
299
348
  const probe = duplicateConnection(client);
300
349
  attachClientErrorListener(probe, __privateGet(this, _logger), "probe");
301
350
  try {
@@ -308,41 +357,62 @@ var ConnectionResolver = class {
308
357
  const client = this.getClient();
309
358
  if (client.status === "ready") return;
310
359
  await new Promise((resolve, reject) => {
360
+ let lastErrorCode;
361
+ const details = (extra) => lastErrorCode === void 0 ? extra : { ...extra, lastErrorCode };
311
362
  const cleanup = () => {
312
363
  clearTimeout(timer);
313
364
  client.off("ready", onReady);
314
365
  client.off("error", onError);
366
+ client.off("end", onEnd);
367
+ };
368
+ const fail = (code, extra) => {
369
+ cleanup();
370
+ reject(new QueueException(code, HttpStatus.INTERNAL_SERVER_ERROR, details(extra)));
315
371
  };
316
372
  const onReady = () => {
317
373
  cleanup();
318
374
  resolve();
319
375
  };
320
376
  const onError = (err) => {
321
- cleanup();
322
- reject(err);
377
+ lastErrorCode = startupErrorCode(err);
378
+ if (isReplyError(err)) fail(QUEUE_ERROR_CODES.CONNECTION_INVALID, {});
379
+ };
380
+ const onEnd = () => {
381
+ fail(QUEUE_ERROR_CODES.CONNECTION_INVALID, {});
323
382
  };
324
383
  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
- );
384
+ fail(QUEUE_ERROR_CODES.CONNECTION_TIMEOUT, { timeoutMs });
333
385
  }, timeoutMs);
334
386
  client.once("ready", onReady);
335
- client.once("error", onError);
387
+ client.on("error", onError);
388
+ client.once("end", onEnd);
336
389
  });
337
390
  }
338
391
  };
339
392
  _logger = new WeakMap();
340
393
  _client = new WeakMap();
341
394
  _options = new WeakMap();
395
+ _pendingReady = new WeakMap();
342
396
  ConnectionResolver = __decorateClass([
343
397
  Injectable(),
344
398
  __decorateParam(0, Inject(BYMAX_QUEUE_OPTIONS))
345
399
  ], ConnectionResolver);
400
+ var WAITING_STATUSES = /* @__PURE__ */ new Set([
401
+ "connecting",
402
+ "connect",
403
+ "reconnecting",
404
+ "close"
405
+ ]);
406
+ function isReplyError(err) {
407
+ return typeof err === "object" && err !== null && err.name === "ReplyError";
408
+ }
409
+ function startupErrorCode(err) {
410
+ if (typeof err !== "object" || err === null) return void 0;
411
+ const { code, message } = err;
412
+ if (typeof code === "string") return code;
413
+ if (isReplyError(err) && typeof message === "string") return /^[A-Z][A-Z0-9_]*/.exec(message)?.[0];
414
+ return void 0;
415
+ }
346
416
  function invalid2(reason) {
347
417
  throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
348
418
  reason
@@ -382,6 +452,51 @@ function attachDefaultErrorListener(emitter, logger, kind, queueName) {
382
452
  });
383
453
  }
384
454
 
455
+ // src/server/utils/with-deadline.ts
456
+ function withDeadline(work, timeoutMs, onTimeout) {
457
+ let timer;
458
+ const deadline = new Promise((_resolve, reject) => {
459
+ timer = setTimeout(() => {
460
+ reject(onTimeout());
461
+ }, timeoutMs);
462
+ });
463
+ return Promise.race([work, deadline]).finally(() => {
464
+ clearTimeout(timer);
465
+ });
466
+ }
467
+ function timeoutException(operation, timeoutMs, cause) {
468
+ const exception = new QueueException(
469
+ QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
470
+ HttpStatus.INTERNAL_SERVER_ERROR,
471
+ { operation, timeoutMs }
472
+ );
473
+ if (cause !== void 0) exception.cause = cause;
474
+ return exception;
475
+ }
476
+ function unavailableException(operation, cause) {
477
+ const exception = new QueueException(
478
+ QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
479
+ HttpStatus.INTERNAL_SERVER_ERROR,
480
+ { operation, reason: "offline" }
481
+ );
482
+ exception.cause = cause;
483
+ return exception;
484
+ }
485
+ function isOfflineRejection(err) {
486
+ return err instanceof Error && err.message === "Stream isn't writeable and enableOfflineQueue options is false";
487
+ }
488
+ function isCommandTimeout(err) {
489
+ return err instanceof Error && err.message === "Command timed out";
490
+ }
491
+ async function withCommandTimeoutCode(operation, commandTimeoutMs, work) {
492
+ try {
493
+ return await work;
494
+ } catch (err) {
495
+ if (isCommandTimeout(err)) throw timeoutException(operation, commandTimeoutMs(), err);
496
+ throw isOfflineRejection(err) ? unavailableException(operation, err) : err;
497
+ }
498
+ }
499
+
385
500
  // src/server/services/queue.service.ts
386
501
  var MAX_BULK_SIZE = 1e3;
387
502
  function assertValidCron(repeat, queueOptions) {
@@ -394,13 +509,14 @@ function assertValidCron(repeat, queueOptions) {
394
509
  });
395
510
  }
396
511
  }
397
- var _queues;
512
+ var _queues, _warm;
398
513
  var QueueService = class {
399
514
  constructor(connection, options) {
400
515
  this.connection = connection;
401
516
  this.options = options;
402
517
  __publicField(this, "logger", new Logger(QueueService.name));
403
518
  __privateAdd(this, _queues, /* @__PURE__ */ new Map());
519
+ __privateAdd(this, _warm, /* @__PURE__ */ new Set());
404
520
  }
405
521
  connection;
406
522
  options;
@@ -419,11 +535,39 @@ var QueueService = class {
419
535
  __privateGet(this, _queues).set(queueName, queue);
420
536
  return queue;
421
537
  }
538
+ call(operation, queueName, run) {
539
+ let expired = false;
540
+ const guarded = withDeadline(
541
+ this.afterReady(queueName).then(() => {
542
+ if (expired) throw new Error("the call had already timed out");
543
+ return run();
544
+ }),
545
+ this.options.enqueueTimeoutMs,
546
+ () => {
547
+ expired = true;
548
+ return timeoutException(operation, this.options.enqueueTimeoutMs);
549
+ }
550
+ );
551
+ return withCommandTimeoutCode(
552
+ operation,
553
+ () => this.connection.getClient().options.commandTimeout ?? this.options.commandTimeoutMs,
554
+ guarded
555
+ );
556
+ }
557
+ async afterReady(queueName) {
558
+ if (__privateGet(this, _warm).has(queueName)) return;
559
+ await this.connection.awaitReady();
560
+ __privateGet(this, _warm).add(queueName);
561
+ }
422
562
  queueFor(queueName) {
423
563
  return this.getOrCreateQueue(queueName);
424
564
  }
425
565
  async enqueue(queueName, jobName, data, options) {
426
- const job = await this.queueFor(queueName).add(jobName, data, options);
566
+ const job = await this.call(
567
+ "enqueue",
568
+ queueName,
569
+ () => this.queueFor(queueName).add(jobName, data, options)
570
+ );
427
571
  return job;
428
572
  }
429
573
  async enqueueBulk(queueName, jobs) {
@@ -439,15 +583,22 @@ var QueueService = class {
439
583
  );
440
584
  }
441
585
  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
- }))
586
+ const created = await this.call(
587
+ "enqueueBulk",
588
+ queueName,
589
+ () => this.queueFor(queueName).addBulk(
590
+ jobs.map((job) => ({
591
+ name: job.name,
592
+ data: job.data,
593
+ ...job.options === void 0 ? {} : { opts: job.options }
594
+ }))
595
+ )
448
596
  );
449
597
  return created;
450
598
  } catch (err) {
599
+ if (err instanceof QueueException && errorCodeOf(err) === QUEUE_ERROR_CODES.CONNECTION_TIMEOUT) {
600
+ throw err;
601
+ }
451
602
  throw new QueueException(
452
603
  QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
453
604
  HttpStatus.INTERNAL_SERVER_ERROR,
@@ -458,20 +609,27 @@ var QueueService = class {
458
609
  }
459
610
  }
460
611
  async getJob(queueName, jobId) {
461
- const job = await this.queueFor(queueName).getJob(jobId);
612
+ const job = await this.call("getJob", queueName, () => this.queueFor(queueName).getJob(jobId));
462
613
  return job ?? null;
463
614
  }
464
615
  async getJobs(queueName, status, start = 0, end = 50) {
465
- const jobs = await this.queueFor(queueName).getJobs([status], start, end);
616
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end - start >= MAX_JOBS_PAGE_SIZE) {
617
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, HttpStatus.BAD_REQUEST, {
618
+ reason: `getJobs needs integers with 0 <= start <= end and at most ${String(MAX_JOBS_PAGE_SIZE)} jobs per call`
619
+ });
620
+ }
621
+ const jobs = await this.call(
622
+ "getJobs",
623
+ queueName,
624
+ () => this.queueFor(queueName).getJobs([status], start, end)
625
+ );
466
626
  return jobs;
467
627
  }
468
628
  async getMetrics(queueName) {
469
- const counts = await this.queueFor(queueName).getJobCounts(
470
- "waiting",
471
- "active",
472
- "completed",
473
- "failed",
474
- "delayed"
629
+ const counts = await this.call(
630
+ "getMetrics",
631
+ queueName,
632
+ () => this.queueFor(queueName).getJobCounts("waiting", "active", "completed", "failed", "delayed")
475
633
  );
476
634
  return {
477
635
  queue: queueName,
@@ -488,23 +646,39 @@ var QueueService = class {
488
646
  ...template?.opts === void 0 ? {} : { opts: template.opts }
489
647
  };
490
648
  if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
491
- const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
649
+ const job = await this.call(
650
+ "upsertJobScheduler",
651
+ queueName,
652
+ () => queue.upsertJobScheduler(schedulerId, repeat, jobTemplate)
653
+ );
492
654
  return job;
493
655
  }
494
656
  async removeJobScheduler(queueName, schedulerId) {
495
- return this.queueFor(queueName).removeJobScheduler(schedulerId);
657
+ return this.call(
658
+ "removeJobScheduler",
659
+ queueName,
660
+ () => this.queueFor(queueName).removeJobScheduler(schedulerId)
661
+ );
496
662
  }
497
663
  async getJobSchedulers(queueName, start = 0, end = 50, asc = true) {
498
- return this.queueFor(queueName).getJobSchedulers(start, end, asc);
664
+ return this.call(
665
+ "getJobSchedulers",
666
+ queueName,
667
+ () => this.queueFor(queueName).getJobSchedulers(start, end, asc)
668
+ );
499
669
  }
500
670
  async pauseQueue(queueName) {
501
- await this.queueFor(queueName).pause();
671
+ await this.call("pauseQueue", queueName, () => this.queueFor(queueName).pause());
502
672
  }
503
673
  async resumeQueue(queueName) {
504
- await this.queueFor(queueName).resume();
674
+ await this.call("resumeQueue", queueName, () => this.queueFor(queueName).resume());
505
675
  }
506
676
  async cleanQueue(queueName, gracePeriodMs, limit, status) {
507
- return this.queueFor(queueName).clean(gracePeriodMs, limit, status);
677
+ return this.call(
678
+ "cleanQueue",
679
+ queueName,
680
+ () => this.queueFor(queueName).clean(gracePeriodMs, limit, status)
681
+ );
508
682
  }
509
683
  getCachedQueues() {
510
684
  return __privateGet(this, _queues);
@@ -514,18 +688,26 @@ var QueueService = class {
514
688
  await queue.close().catch(() => void 0);
515
689
  }
516
690
  __privateGet(this, _queues).clear();
691
+ __privateGet(this, _warm).clear();
517
692
  }
518
693
  };
519
694
  _queues = new WeakMap();
695
+ _warm = new WeakMap();
520
696
  QueueService = __decorateClass([
521
697
  Injectable(),
522
698
  __decorateParam(0, Inject(ConnectionResolver)),
523
699
  __decorateParam(1, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
524
700
  ], QueueService);
701
+ function errorCodeOf(err) {
702
+ return err.getResponse().error.code;
703
+ }
704
+ var _connection;
525
705
  var FlowService = class {
526
- logger = new Logger(FlowService.name);
527
- producer;
528
706
  constructor(connection, enabled, prefix, telemetry) {
707
+ __publicField(this, "logger", new Logger(FlowService.name));
708
+ __publicField(this, "producer");
709
+ __privateAdd(this, _connection);
710
+ __privateSet(this, _connection, connection);
529
711
  if (enabled) {
530
712
  this.producer = new FlowProducer({
531
713
  connection: connection.getClient(),
@@ -536,10 +718,21 @@ var FlowService = class {
536
718
  }
537
719
  }
538
720
  async add(flow) {
539
- return this.requireProducer().add(flow);
721
+ return withCommandTimeoutCode(
722
+ "addFlow",
723
+ () => this.commandTimeout(),
724
+ this.requireProducer().add(flow)
725
+ );
540
726
  }
541
727
  async addBulk(flows) {
542
- return this.requireProducer().addBulk([...flows]);
728
+ return withCommandTimeoutCode(
729
+ "addFlowBulk",
730
+ () => this.commandTimeout(),
731
+ this.requireProducer().addBulk([...flows])
732
+ );
733
+ }
734
+ commandTimeout() {
735
+ return __privateGet(this, _connection).getClient().options.commandTimeout ?? 0;
543
736
  }
544
737
  getProducer() {
545
738
  return this.requireProducer();
@@ -556,6 +749,7 @@ var FlowService = class {
556
749
  return this.producer;
557
750
  }
558
751
  };
752
+ _connection = new WeakMap();
559
753
  FlowService = __decorateClass([
560
754
  Injectable()
561
755
  ], FlowService);
@@ -601,6 +795,17 @@ var MetricsService = class {
601
795
  MetricsService = __decorateClass([
602
796
  Injectable()
603
797
  ], MetricsService);
798
+
799
+ // src/server/utils/abandoned-workers.ts
800
+ var abandoned = /* @__PURE__ */ new WeakSet();
801
+ function abandonWorker(worker) {
802
+ abandoned.add(worker);
803
+ }
804
+ function isAbandoned(worker) {
805
+ return abandoned.has(worker);
806
+ }
807
+
808
+ // src/server/services/worker-registry.service.ts
604
809
  var _entries;
605
810
  var WorkerRegistry = class {
606
811
  constructor(connection, options) {
@@ -619,7 +824,11 @@ var WorkerRegistry = class {
619
824
  const bullOpts = this.buildBullOptions(options, conn);
620
825
  let worker;
621
826
  try {
622
- worker = new Worker(queueName, handler, bullOpts);
827
+ worker = new Worker(
828
+ queueName,
829
+ toProcessor(handler, options?.jobTimeoutMs, () => isAbandoned(worker)),
830
+ bullOpts
831
+ );
623
832
  } catch (cause) {
624
833
  conn.disconnect();
625
834
  throw new QueueException(
@@ -637,6 +846,13 @@ var WorkerRegistry = class {
637
846
  }
638
847
  registerSandboxed(config) {
639
848
  const { queueName, processorFile, options } = config;
849
+ if (options?.jobTimeoutMs !== void 0) {
850
+ throw new QueueException(
851
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
852
+ HttpStatus.INTERNAL_SERVER_ERROR,
853
+ { reason: "jobTimeoutMs is not available for sandboxed workers" }
854
+ );
855
+ }
640
856
  this.validateProcessorFile(processorFile);
641
857
  this.guardDuplicate(queueName);
642
858
  this.validateOptions(options);
@@ -666,9 +882,12 @@ var WorkerRegistry = class {
666
882
  async unregister(queueName) {
667
883
  const entry = __privateGet(this, _entries).get(queueName);
668
884
  if (!entry) return;
669
- await entry.worker.close();
670
- await this.closeConnection(entry.connection);
671
- __privateGet(this, _entries).delete(queueName);
885
+ try {
886
+ await entry.worker.close();
887
+ } finally {
888
+ await this.closeConnection(entry.connection);
889
+ __privateGet(this, _entries).delete(queueName);
890
+ }
672
891
  }
673
892
  list() {
674
893
  return Array.from(__privateGet(this, _entries).keys());
@@ -722,6 +941,7 @@ var WorkerRegistry = class {
722
941
  );
723
942
  }
724
943
  }
944
+ this.validateJobTimeout(opts);
725
945
  if (opts?.limiter !== void 0) {
726
946
  if (opts.limiter.max < 1) {
727
947
  throw new QueueException(
@@ -745,6 +965,18 @@ var WorkerRegistry = class {
745
965
  }
746
966
  }
747
967
  }
968
+ validateJobTimeout(opts) {
969
+ if (opts?.jobTimeoutMs !== void 0 && !isTimerDelayMs(opts.jobTimeoutMs)) {
970
+ throw new QueueException(
971
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
972
+ HttpStatus.INTERNAL_SERVER_ERROR,
973
+ {
974
+ reason: "jobTimeoutMs must be a whole number from 1 to 2147483647",
975
+ actual: opts.jobTimeoutMs
976
+ }
977
+ );
978
+ }
979
+ }
748
980
  validateProcessorFile(processorFile) {
749
981
  if (processorFile instanceof URL) {
750
982
  if (processorFile.protocol !== "file:") {
@@ -802,6 +1034,44 @@ WorkerRegistry = __decorateClass([
802
1034
  __decorateParam(0, Inject(ConnectionResolver)),
803
1035
  __decorateParam(1, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
804
1036
  ], WorkerRegistry);
1037
+ var JobTimeoutError = class extends Error {
1038
+ constructor(timeoutMs) {
1039
+ super(`queue.job_timeout: the handler did not settle within ${String(timeoutMs)}ms`);
1040
+ this.name = "JobTimeoutError";
1041
+ }
1042
+ };
1043
+ function adaptHandler(handler, timeoutMs) {
1044
+ if (timeoutMs === void 0) return (job, _token, signal) => handler(job, signal);
1045
+ return (job, _token, signal) => {
1046
+ const own = new AbortController();
1047
+ const relay = () => {
1048
+ own.abort(signal?.reason);
1049
+ };
1050
+ if (signal?.aborted === true) relay();
1051
+ signal?.addEventListener("abort", relay);
1052
+ return withDeadline(
1053
+ Promise.resolve().then(() => handler(job, own.signal)),
1054
+ timeoutMs,
1055
+ () => {
1056
+ const error = new JobTimeoutError(timeoutMs);
1057
+ own.abort(error);
1058
+ return error;
1059
+ }
1060
+ ).finally(() => {
1061
+ signal?.removeEventListener("abort", relay);
1062
+ });
1063
+ };
1064
+ }
1065
+ function toProcessor(handler, timeoutMs, abandoned2) {
1066
+ const run = adaptHandler(handler, timeoutMs);
1067
+ return (job, token, signal) => run(job, token, signal).then(
1068
+ (value) => abandoned2() ? new Promise(() => void 0) : value,
1069
+ (error) => {
1070
+ if (abandoned2()) return new Promise(() => void 0);
1071
+ throw error;
1072
+ }
1073
+ );
1074
+ }
805
1075
  var _events, _connections;
806
1076
  var QueueEventsRegistry = class {
807
1077
  constructor(connection, options) {
@@ -891,10 +1161,10 @@ var ProcessorDiscoveryService = class {
891
1161
  catchAll = fn;
892
1162
  }
893
1163
  }
894
- return (job) => {
1164
+ return (job, signal) => {
895
1165
  const specific = namedHandlers.get(job.name);
896
- if (specific) return specific(job);
897
- if (catchAll) return catchAll(job);
1166
+ if (specific) return specific(job, signal);
1167
+ if (catchAll) return catchAll(job, signal);
898
1168
  return Promise.reject(
899
1169
  new Error(
900
1170
  `No handler registered for job name "${job.name}" on queue \u2014 add a matching @Process("name") or a catch-all @Process() method.`
@@ -980,6 +1250,7 @@ ProcessorDiscoveryService = __decorateClass([
980
1250
  __decorateParam(1, Inject(WorkerRegistry)),
981
1251
  __decorateParam(2, Inject(QueueEventsRegistry))
982
1252
  ], ProcessorDiscoveryService);
1253
+ var FORCE_CLOSE_GRACE_MS = 1e3;
983
1254
  var DrainTimeoutError = class extends Error {
984
1255
  };
985
1256
  var QueueLifecycle = class {
@@ -1002,9 +1273,10 @@ var QueueLifecycle = class {
1002
1273
  forcedWorkers = 0;
1003
1274
  async onModuleDestroy() {
1004
1275
  this.startedAt = Date.now();
1005
- this.forcedWorkers = await this.closeWorkers();
1006
- await this.closeQueueEvents();
1007
- await this.closeDuplicatedConnections();
1276
+ const deadline = this.startedAt + this.resolved.shutdown.drainTimeoutMs;
1277
+ this.forcedWorkers = await this.closeWorkers(deadline);
1278
+ await this.closeQueueEvents(deadline);
1279
+ await this.closeDuplicatedConnections(deadline);
1008
1280
  }
1009
1281
  async onApplicationShutdown() {
1010
1282
  await this.drainQueuesIfRequested();
@@ -1012,59 +1284,53 @@ var QueueLifecycle = class {
1012
1284
  await this.closeQueues();
1013
1285
  await this.teardownConnection();
1014
1286
  this.logger.log(
1015
- `Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (forced ${String(this.forcedWorkers)} worker(s))`
1287
+ `Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (${String(this.forcedWorkers)} worker(s) stopped without draining)`
1016
1288
  );
1017
1289
  }
1018
- async closeWorkers() {
1019
- 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
- }
1032
- return forced;
1033
- }
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
- }
1290
+ async closeWorkers(deadline) {
1291
+ let undrained = 0;
1292
+ await Promise.all(
1293
+ Array.from(this.workers.getAll(), async ([name, worker]) => {
1294
+ try {
1295
+ await bounded(
1296
+ worker.pause().then(() => worker.close()),
1297
+ deadline
1298
+ );
1299
+ } catch (cause) {
1300
+ undrained++;
1301
+ abandonWorker(worker);
1302
+ const stopped = "it is stopped without waiting for its in-flight job, which becomes stalled and is retried";
1303
+ this.logger.warn(
1304
+ cause instanceof DrainTimeoutError ? `Worker "${name}" exceeded the ${String(this.resolved.shutdown.drainTimeoutMs)}ms drain budget (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); ${stopped}` : `Worker "${name}" failed to close gracefully (${describeError(cause)}); ${stopped}`
1305
+ );
1306
+ const deadlineError = () => new DrainTimeoutError();
1307
+ await withDeadline(worker.close(true), FORCE_CLOSE_GRACE_MS, deadlineError).catch(
1308
+ () => void 0
1309
+ );
1310
+ }
1311
+ })
1312
+ );
1313
+ return undrained;
1050
1314
  }
1051
- async closeQueueEvents() {
1052
- for (const [, queueEvents] of this.events.getAll()) {
1053
- await queueEvents.close().catch(() => void 0);
1054
- }
1315
+ async closeQueueEvents(deadline) {
1316
+ await Promise.all(
1317
+ Array.from(this.events.getAll().values(), async (queueEvents) => {
1318
+ await bounded(queueEvents.close(), deadline).catch(() => void 0);
1319
+ })
1320
+ );
1055
1321
  }
1056
- async closeDuplicatedConnections() {
1322
+ async closeDuplicatedConnections(deadline) {
1057
1323
  const connections = [
1058
1324
  ...this.workers.getConnections().values(),
1059
1325
  ...this.events.getConnections().values()
1060
1326
  ];
1061
- for (const connection of connections) {
1062
- try {
1063
- await connection.quit();
1064
- } catch {
1065
- connection.disconnect();
1066
- }
1067
- }
1327
+ await Promise.all(
1328
+ connections.map(async (connection) => {
1329
+ await bounded(connection.quit(), deadline).catch(() => {
1330
+ connection.disconnect();
1331
+ });
1332
+ })
1333
+ );
1068
1334
  }
1069
1335
  async drainQueuesIfRequested() {
1070
1336
  if (!this.resolved.shutdown.drainOnShutdown) return;
@@ -1095,6 +1361,9 @@ QueueLifecycle = __decorateClass([
1095
1361
  __decorateParam(4, Inject(ConnectionResolver)),
1096
1362
  __decorateParam(5, Inject(BYMAX_QUEUE_RESOLVED_OPTIONS))
1097
1363
  ], QueueLifecycle);
1364
+ function bounded(work, deadline) {
1365
+ return withDeadline(work, Math.max(0, deadline - Date.now()), () => new DrainTimeoutError());
1366
+ }
1098
1367
 
1099
1368
  // src/server/bymax-queue.module.ts
1100
1369
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (