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