@rvoh/psychic-workers 2.6.0 → 2.7.0

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.
Files changed (36) hide show
  1. package/dist/cjs/src/background/BaseBackgroundedModel.js +49 -25
  2. package/dist/cjs/src/background/BaseBackgroundedService.js +24 -12
  3. package/dist/cjs/src/background/BaseScheduledService.js +1 -1
  4. package/dist/cjs/src/background/index.js +383 -98
  5. package/dist/cjs/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
  6. package/dist/cjs/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
  7. package/dist/cjs/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
  8. package/dist/cjs/src/error/background/RateLimitedPsychicJob.js +82 -0
  9. package/dist/cjs/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
  10. package/dist/cjs/src/package-exports/errors.js +7 -0
  11. package/dist/cjs/src/test-utils/WorkerTestUtils.js +78 -13
  12. package/dist/esm/src/background/BaseBackgroundedModel.js +49 -25
  13. package/dist/esm/src/background/BaseBackgroundedService.js +24 -12
  14. package/dist/esm/src/background/BaseScheduledService.js +1 -1
  15. package/dist/esm/src/background/index.js +383 -98
  16. package/dist/esm/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
  17. package/dist/esm/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
  18. package/dist/esm/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
  19. package/dist/esm/src/error/background/RateLimitedPsychicJob.js +82 -0
  20. package/dist/esm/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
  21. package/dist/esm/src/package-exports/errors.js +7 -0
  22. package/dist/esm/src/test-utils/WorkerTestUtils.js +78 -13
  23. package/dist/types/src/background/BaseBackgroundedModel.d.ts +49 -25
  24. package/dist/types/src/background/BaseBackgroundedService.d.ts +24 -12
  25. package/dist/types/src/background/BaseScheduledService.d.ts +1 -1
  26. package/dist/types/src/background/index.d.ts +109 -5
  27. package/dist/types/src/error/background/DebouncedJobOutpacesRateLimit.d.ts +31 -0
  28. package/dist/types/src/error/background/DebouncedJobRequiresMinimumDelay.d.ts +23 -0
  29. package/dist/types/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.d.ts +16 -0
  30. package/dist/types/src/error/background/RateLimitedPsychicJob.d.ts +73 -0
  31. package/dist/types/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.d.ts +16 -0
  32. package/dist/types/src/package-exports/errors.d.ts +1 -0
  33. package/dist/types/src/psychic-app-workers/index.d.ts +102 -53
  34. package/dist/types/src/test-utils/WorkerTestUtils.d.ts +32 -1
  35. package/dist/types/src/types/background.d.ts +88 -6
  36. package/package.json +1 -1
@@ -2,26 +2,91 @@ import { Dream } from '@rvoh/dream';
2
2
  import { closeAllDbConnections } from '@rvoh/dream/db';
3
3
  import { compact, pascalize } from '@rvoh/dream/utils';
4
4
  import { PsychicApp } from '@rvoh/psychic';
5
- import { Job, Queue, Worker, } from 'bullmq';
5
+ import { Job, Queue, RateLimitError, Worker, } from 'bullmq';
6
6
  import ActivatingBackgroundWorkersWithoutDefaultWorkerConnection from '../error/background/ActivatingBackgroundWorkersWithoutDefaultWorkerConnection.js';
7
7
  import ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection from '../error/background/ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection.js';
8
+ import DebouncedJobRequiresMinimumDelay from '../error/background/DebouncedJobRequiresMinimumDelay.js';
8
9
  import DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
9
10
  import NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
11
+ import NamedWorkstreamRateLimitMissingMaxOrDuration from '../error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js';
10
12
  import NoClassForSpecifiedGlobalName from '../error/background/NoClassForSpecifiedGlobalName.js';
11
13
  import NoQueueForSpecifiedQueueName from '../error/background/NoQueueForSpecifiedQueueName.js';
12
14
  import NoQueueForSpecifiedWorkstream from '../error/background/NoQueueForSpecifiedWorkstream.js';
15
+ import RateLimitedPsychicJob from '../error/background/RateLimitedPsychicJob.js';
16
+ import RateLimitedPsychicJobThrownFromWorkerWithoutLimiter from '../error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js';
13
17
  import EnvInternal from '../helpers/EnvInternal.js';
14
18
  import PsychicAppWorkers from '../psychic-app-workers/index.js';
15
19
  import AttemtedToBackgroundEntireDreamModel from '../error/background/AttemtedToBackgroundEntireDreamModel.js';
20
+ import DebouncedJobOutpacesRateLimit from '../error/background/DebouncedJobOutpacesRateLimit.js';
16
21
  import nameToRedisQueueName from './helpers/nameToRedisQueueName.js';
17
22
  const DEFAULT_CONCURRENCY = 10;
23
+ /**
24
+ * the shortest delay a `jobId` (deduplication key) may be paired with.
25
+ *
26
+ * The floor is set by arithmetic against {@link DEBOUNCE_KEY_MARGIN_MS},
27
+ * not by how long a worker takes to pick a job up. BullMQ rearms the key for
28
+ * its full `ttl` on every call that replaces the pending job (`SET ... PX` in
29
+ * `deduplicateJob.lua`, on the `replace`/`extend` path this package uses), so
30
+ * the key's life runs from the **last** call rather than the first. A burst is
31
+ * therefore collapsed however long it lasts, and the only thing that can split
32
+ * it is a single gap between consecutive calls longer than the key's life —
33
+ * which is `delay - DEBOUNCE_KEY_MARGIN_MS`. The floor is what that
34
+ * subtraction is allowed to leave: at three seconds a burst tolerates a
35
+ * two-second lull, twice the margin, so an ordinary round trip or pause inside
36
+ * a burst cannot split it.
37
+ *
38
+ * Below about two seconds the subtraction stops leaving anything usable: at a
39
+ * two-second delay the tolerated gap is 1000ms — the same scale as the margin
40
+ * itself, which exists precisely to absorb noise at that scale — and at a delay
41
+ * of one second it reaches the `Math.max(1, …)` clamp in `_addToQueue` and
42
+ * silently degenerates to a 1ms key. Refusing below the floor is deliberate, in
43
+ * preference to silently raising the delay or debouncing nothing.
44
+ *
45
+ * Three consequences of the margin, all verified against BullMQ's Lua and
46
+ * stated nowhere else:
47
+ *
48
+ * - The last `DEBOUNCE_KEY_MARGIN_MS` of every window debounces nothing,
49
+ * because the key dies before the job fires. At the floor that dead band is
50
+ * a third of the window; at an hour it is 0.03%. A caller whose
51
+ * cadence lands inside the band degrades to no debounce at all, not to an
52
+ * occasional extra run. The margin is flat, so longer delays are strictly
53
+ * cheaper.
54
+ * - Promoting or re-delaying a debounced job by hand (`job.promote()`,
55
+ * `job.changeDelay()`) moves the job out from under its key without touching
56
+ * the key, so later calls collapse into a job that is no longer pending and
57
+ * the run after the last call never happens. Follow either with
58
+ * `queue.removeDeduplicationKey(jobId)`.
59
+ * - Collapsing only happens while the job is still in the delayed set. If
60
+ * promotion stalls — rate limited, paused, saturated, or no worker running —
61
+ * the key expires while the job sits there and every further call adds its
62
+ * own delayed job for the rest of the stall. Extra runs, not missing ones.
63
+ *
64
+ * The floor is a refusal, so it is cheap to lower and breaking to raise.
65
+ */
66
+ const MINIMUM_DEBOUNCE_DELAY_MS = 3000;
67
+ /**
68
+ * how far short of the delay the deduplication key's lifetime is set. The key's
69
+ * expiry is armed from the Redis clock when the script lands, while the job's
70
+ * fire time comes from the producer's clock, so a key with a lifetime equal to
71
+ * the delay outlives its own job by one add round trip plus clock skew — and a
72
+ * call landing in that gap is swallowed after the job has already run, which
73
+ * breaks the debounce guarantee. That window is one round trip wide however
74
+ * long the delay is, so the margin is flat rather than proportional.
75
+ */
76
+ const DEBOUNCE_KEY_MARGIN_MS = 1000;
18
77
  /**
19
78
  * the underlying class driving the `background` singleton,
20
79
  * available as an import from `psychic-workers`.
21
80
  */
22
81
  export class Background {
23
82
  /**
24
- * returns the default queue name for your app
83
+ * returns the **logical** name of your app's default queue, built from your
84
+ * app name (`MyAppBackgroundJobQueue`).
85
+ *
86
+ * This is never the name the queue has in Redis: `connect` rewrites it per
87
+ * connection (brace-stripped, cluster hash-tagged, test-suffixed), and every
88
+ * key BullMQ writes is namespaced by the rewritten name. Do not compose Redis
89
+ * keys from this — read `queue.name` off `background.queues` instead.
25
90
  */
26
91
  static get defaultQueueName() {
27
92
  const psychicWorkersApp = PsychicAppWorkers.getOrFail();
@@ -98,22 +163,61 @@ export class Background {
98
163
  */
99
164
  redisConnections = [];
100
165
  /**
101
- * Establishes connection to BullMQ via redis
166
+ * @internal
167
+ *
168
+ * For every queue built by `connect`: how it was configured, whether its
169
+ * workers carry a BullMQ `limiter`, and what the workers are built from.
170
+ * Written whether or not workers are activated here, so activation can happen
171
+ * later without re-walking the configuration. Keyed by Queue identity rather
172
+ * than name: a transitional workstream's queue has the same formatted name as
173
+ * its current twin but its own configuration.
174
+ */
175
+ queueWorkerRecords = new Map();
176
+ /**
177
+ * @internal
178
+ *
179
+ * set immediately before {@link buildRecordedWorkers} runs its build loop, so
180
+ * that a second activation builds nothing
181
+ */
182
+ workersActivated = false;
183
+ /**
184
+ * Establishes connection to BullMQ via redis: builds the `Queue` objects for
185
+ * the default and named workstreams and, only when `activateWorkers` is true,
186
+ * the `Worker` objects that run jobs off them. Synchronous: it returns
187
+ * nothing, so it is called without `await` even from async code.
188
+ *
189
+ * Connecting and activating are separate. `activateWorkers` defaults to
190
+ * `false`, so connecting gets the producer side only. Queues are built once
191
+ * per instance, but a later activation still builds workers, so a process that
192
+ * connects first and calls `work()` afterwards gets its workers.
193
+ *
194
+ * You rarely call this yourself — Psychic connects on the
195
+ * `server:init:after-routes` hook, on every enqueue path, in CLI codegen and
196
+ * in the `WorkerTestUtils` helpers.
102
197
  */
103
198
  connect({ activateWorkers = false, } = {}) {
104
- if (this.defaultQueue)
105
- return;
106
- const psychicWorkersApp = PsychicAppWorkers.getOrFail();
107
- const defaultBullMQQueueOptions = psychicWorkersApp.backgroundOptions.defaultBullMQQueueOptions || {};
108
- if (psychicWorkersApp.backgroundOptions.nativeBullMQ) {
109
- this.nativeBullMQConnect(defaultBullMQQueueOptions, psychicWorkersApp.backgroundOptions, { activateWorkers });
110
- }
111
- else {
112
- this.simpleConnect(defaultBullMQQueueOptions, psychicWorkersApp.backgroundOptions, { activateWorkers });
199
+ if (!this.defaultQueue) {
200
+ const psychicWorkersApp = PsychicAppWorkers.getOrFail();
201
+ const defaultBullMQQueueOptions = psychicWorkersApp.backgroundOptions.defaultBullMQQueueOptions || {};
202
+ if (psychicWorkersApp.backgroundOptions.nativeBullMQ) {
203
+ this.nativeBullMQConnect(defaultBullMQQueueOptions, psychicWorkersApp.backgroundOptions);
204
+ }
205
+ else {
206
+ this.simpleConnect(defaultBullMQQueueOptions, psychicWorkersApp.backgroundOptions);
207
+ }
113
208
  }
209
+ if (activateWorkers)
210
+ this.buildRecordedWorkers();
114
211
  }
115
212
  /**
116
- * Returns all the queues in your application
213
+ * Returns all the queues in your application: the default queue, every named
214
+ * queue, and their transitional twins when transitional workstreams are
215
+ * configured.
216
+ *
217
+ * `connect` is what populates this, and before it has run the getter returns
218
+ * an **empty array**, with no error and no warning — indistinguishable from
219
+ * an application with nothing queued. Each `Queue` here carries the rewritten
220
+ * Redis name rather than the name you configured.
117
221
  */
118
222
  get queues() {
119
223
  return compact([
@@ -124,7 +228,14 @@ export class Background {
124
228
  ]);
125
229
  }
126
230
  /**
127
- * Returns all the workers in your application
231
+ * Returns all the workers in your application: every BullMQ `Worker` this
232
+ * process has built.
233
+ *
234
+ * Only a process that activated workers has any — `work()`, or a direct
235
+ * `connect({ activateWorkers: true })`. Anywhere else the getter returns an
236
+ * **empty array**, with no error and no warning, so a worker entrypoint that
237
+ * attaches failure listeners by looping over it before `work()` runs attaches
238
+ * nothing and logs nothing to say so.
128
239
  */
129
240
  get workers() {
130
241
  return [...this._workers];
@@ -233,7 +344,13 @@ export class Background {
233
344
  *
234
345
  * connects to BullMQ using workstream-based arguments
235
346
  */
236
- simpleConnect(defaultBullMQQueueOptions, backgroundOptions, { activateWorkers = false, activatingTransitionalWorkstreams = false, }) {
347
+ simpleConnect(defaultBullMQQueueOptions, backgroundOptions, { activatingTransitionalWorkstreams = false, } = {}) {
348
+ // refused before any queue or worker of this connect exists, for the current and the
349
+ // transitional workstreams alike (the transitional re-entry below has already been checked)
350
+ if (!activatingTransitionalWorkstreams) {
351
+ this.assertNamedWorkstreamRateLimits(backgroundOptions.namedWorkstreams, false);
352
+ this.assertNamedWorkstreamRateLimits(backgroundOptions.transitionalWorkstreams?.namedWorkstreams, true);
353
+ }
237
354
  const defaultQueueConnection = backgroundOptions.defaultQueueConnection;
238
355
  const defaultWorkerConnection = backgroundOptions.defaultWorkerConnection;
239
356
  if (defaultQueueConnection)
@@ -263,19 +380,17 @@ export class Background {
263
380
  /////////////////////////////
264
381
  // create default workers //
265
382
  /////////////////////////////
266
- if (activateWorkers) {
267
- if (!defaultWorkerConnection)
268
- throw new ActivatingBackgroundWorkersWithoutDefaultWorkerConnection();
269
- const workerCount = backgroundOptions.defaultWorkstream?.workerCount ?? 1;
270
- for (let i = 0; i < workerCount; i++) {
271
- this._workers.push(new Background.Worker(formattedQueueName, async (job) => await this.doWork(job), {
272
- autorun: !EnvInternal.isTest,
273
- ...backgroundOptions.defaultBullMQWorkerOptions,
274
- connection: defaultWorkerConnection,
275
- concurrency: backgroundOptions.defaultWorkstream?.concurrency || DEFAULT_CONCURRENCY,
276
- }));
277
- }
278
- }
383
+ const defaultWorkerOptions = {
384
+ autorun: !EnvInternal.isTest,
385
+ ...backgroundOptions.defaultBullMQWorkerOptions,
386
+ concurrency: backgroundOptions.defaultWorkstream?.concurrency || DEFAULT_CONCURRENCY,
387
+ };
388
+ this.recordQueueWorkers(defaultQueue, defaultWorkerOptions, defaultWorkerConnection, backgroundOptions.defaultWorkstream?.workerCount ?? 1, {
389
+ mode: 'simple',
390
+ isDefaultQueue: true,
391
+ configuredName: Background.defaultQueueName,
392
+ transitional: activatingTransitionalWorkstreams,
393
+ });
279
394
  /////////////////////////////////
280
395
  // end: create default workers //
281
396
  /////////////////////////////////
@@ -308,24 +423,27 @@ export class Background {
308
423
  //////////////////////////
309
424
  // create named workers //
310
425
  //////////////////////////
311
- if (activateWorkers) {
312
- if (!namedWorkstreamWorkerConnection)
313
- throw new ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection(namedWorkstream.name);
314
- const workerCount = namedWorkstream.workerCount ?? 1;
315
- for (let i = 0; i < workerCount; i++) {
316
- this._workers.push(new Background.Worker(namedWorkstreamFormattedQueueName, async (job) => await this.doWork(job), {
317
- autorun: !EnvInternal.isTest,
318
- ...backgroundOptions.defaultBullMQWorkerOptions,
319
- group: {
320
- id: namedWorkstream.name,
321
- limit: namedWorkstream.rateLimit,
322
- },
323
- connection: namedWorkstreamWorkerConnection,
324
- concurrency: namedWorkstream.concurrency || DEFAULT_CONCURRENCY,
325
- // explicitly typing as WorkerOptions because Psychic can't be aware of BullMQ Pro options
326
- }));
327
- }
328
- }
426
+ const namedWorkerOptions = {
427
+ autorun: !EnvInternal.isTest,
428
+ ...backgroundOptions.defaultBullMQWorkerOptions,
429
+ // open-source BullMQ rate limiting (queue-wide, shared by every worker on this queue);
430
+ // conditional spread so the key is absent when no rateLimit is configured
431
+ ...(namedWorkstream.rateLimit ? { limiter: namedWorkstream.rateLimit } : {}),
432
+ // BullMQ Pro option (ignored by open-source BullMQ); Psychic can't be aware of BullMQ Pro options
433
+ group: {
434
+ // Pro's worker `group` option is `{ limit, concurrency }` with no `id` (grouping
435
+ // happens at job add, see `_addToQueue`); not verified against an installed Pro build
436
+ id: namedWorkstream.name,
437
+ limit: namedWorkstream.rateLimit,
438
+ },
439
+ concurrency: namedWorkstream.concurrency || DEFAULT_CONCURRENCY,
440
+ };
441
+ this.recordQueueWorkers(namedQueue, namedWorkerOptions, namedWorkstreamWorkerConnection, namedWorkstream.workerCount ?? 1, {
442
+ mode: 'simple',
443
+ isDefaultQueue: false,
444
+ configuredName: namedWorkstream.name,
445
+ transitional: activatingTransitionalWorkstreams,
446
+ });
329
447
  ///////////////////////////////
330
448
  // end: create named workers //
331
449
  ///////////////////////////////
@@ -337,7 +455,6 @@ export class Background {
337
455
  .transitionalWorkstreams;
338
456
  if (transitionalWorkstreams) {
339
457
  this.simpleConnect(defaultBullMQQueueOptions, transitionalWorkstreams, {
340
- activateWorkers,
341
458
  activatingTransitionalWorkstreams: true,
342
459
  });
343
460
  }
@@ -347,7 +464,7 @@ export class Background {
347
464
  *
348
465
  * connects to BullMQ using native BullMQ arguments
349
466
  */
350
- nativeBullMQConnect(defaultBullMQQueueOptions, backgroundOptions, { activateWorkers = false, }) {
467
+ nativeBullMQConnect(defaultBullMQQueueOptions, backgroundOptions) {
351
468
  const nativeBullMQ = backgroundOptions.nativeBullMQ;
352
469
  const defaultQueueConnection = nativeBullMQ.defaultQueueOptions?.queueConnection || backgroundOptions.defaultQueueConnection;
353
470
  const defaultWorkerConnection = nativeBullMQ.defaultQueueOptions?.workerConnection || backgroundOptions.defaultWorkerConnection;
@@ -361,30 +478,29 @@ export class Background {
361
478
  //////////////////////////
362
479
  // create default queue //
363
480
  //////////////////////////
364
- this.defaultQueue = new Background.Queue(formattedQueueName, {
481
+ const defaultQueue = new Background.Queue(formattedQueueName, {
365
482
  ...defaultBullMQQueueOptions,
366
483
  ...nativeBullMQ.defaultQueueOptions,
367
484
  connection: defaultQueueConnection,
368
485
  });
486
+ this.defaultQueue = defaultQueue;
369
487
  ///////////////////////////////
370
488
  // end: create default queue //
371
489
  ///////////////////////////////
372
490
  /////////////////////////////
373
491
  // create default workers //
374
492
  /////////////////////////////
375
- if (activateWorkers) {
376
- if (!defaultWorkerConnection)
377
- throw new ActivatingBackgroundWorkersWithoutDefaultWorkerConnection();
378
- const workerCount = nativeBullMQ.defaultWorkerCount ?? 1;
379
- for (let i = 0; i < workerCount; i++) {
380
- this._workers.push(new Background.Worker(formattedQueueName, async (job) => await this.doWork(job), {
381
- autorun: !EnvInternal.isTest,
382
- ...backgroundOptions.defaultBullMQWorkerOptions,
383
- ...backgroundOptions.nativeBullMQ.defaultWorkerOptions,
384
- connection: defaultWorkerConnection,
385
- }));
386
- }
387
- }
493
+ const defaultWorkerOptions = {
494
+ autorun: !EnvInternal.isTest,
495
+ ...backgroundOptions.defaultBullMQWorkerOptions,
496
+ ...backgroundOptions.nativeBullMQ.defaultWorkerOptions,
497
+ };
498
+ this.recordQueueWorkers(defaultQueue, defaultWorkerOptions, defaultWorkerConnection, nativeBullMQ.defaultWorkerCount ?? 1, {
499
+ mode: 'native',
500
+ isDefaultQueue: true,
501
+ configuredName: Background.defaultQueueName,
502
+ transitional: false,
503
+ });
388
504
  /////////////////////////////////
389
505
  // end: create default workers //
390
506
  /////////////////////////////////
@@ -403,11 +519,12 @@ export class Background {
403
519
  if (!namedQueueConnection)
404
520
  throw new NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection(queueName);
405
521
  const formattedQueuename = nameToRedisQueueName(queueName, namedQueueConnection);
406
- this.namedQueues[queueName] = new Background.Queue(formattedQueuename, {
522
+ const namedQueue = new Background.Queue(formattedQueuename, {
407
523
  ...defaultBullMQQueueOptions,
408
524
  ...namedQueueOptions,
409
525
  connection: namedQueueConnection,
410
526
  });
527
+ this.namedQueues[queueName] = namedQueue;
411
528
  //////////////////////////
412
529
  // create extra workers //
413
530
  //////////////////////////
@@ -417,18 +534,17 @@ export class Background {
417
534
  this.groupNames[queueName] ||= [];
418
535
  if (extraWorkerOptions?.group?.id)
419
536
  this.groupNames[queueName].push(extraWorkerOptions.group.id);
420
- if (activateWorkers) {
421
- if (!namedWorkerConnection)
422
- throw new ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection(queueName);
423
- for (let i = 0; i < extraWorkerCount; i++) {
424
- this._workers.push(new Background.Worker(formattedQueuename, async (job) => await this.doWork(job), {
425
- autorun: !EnvInternal.isTest,
426
- ...backgroundOptions.defaultBullMQWorkerOptions,
427
- ...extraWorkerOptions,
428
- connection: namedWorkerConnection,
429
- }));
430
- }
431
- }
537
+ const namedWorkerOptions = {
538
+ autorun: !EnvInternal.isTest,
539
+ ...backgroundOptions.defaultBullMQWorkerOptions,
540
+ ...extraWorkerOptions,
541
+ };
542
+ this.recordQueueWorkers(namedQueue, namedWorkerOptions, namedWorkerConnection, extraWorkerCount, {
543
+ mode: 'native',
544
+ isDefaultQueue: false,
545
+ configuredName: queueName,
546
+ transitional: false,
547
+ });
432
548
  ///////////////////////////////
433
549
  // end: create extra workers //
434
550
  ///////////////////////////////
@@ -438,7 +554,123 @@ export class Background {
438
554
  //////////////////////////////
439
555
  }
440
556
  /**
441
- * starts background workers
557
+ * @internal
558
+ *
559
+ * the sole writer of {@link queueWorkerRecords}. `workerOptions` is the very
560
+ * object the workers are built from (minus the connection), so the record and
561
+ * the workers cannot disagree.
562
+ */
563
+ recordQueueWorkers(queue, workerOptions, workerConnection, workerCount, description) {
564
+ this.queueWorkerRecords.set(queue, {
565
+ description,
566
+ hasLimiter: Boolean(workerOptions.limiter),
567
+ workerOptions,
568
+ workerConnection,
569
+ workerCount,
570
+ });
571
+ }
572
+ /**
573
+ * @internal
574
+ *
575
+ * builds every worker `connect` recorded a queue for, in the order the queues
576
+ * were built, so the default missing-connection throw still pre-empts the
577
+ * named one. The activation flag is set before the loop, not after it, because
578
+ * `_workers` is append-only and a build that throws partway must not be
579
+ * retryable into a second copy of the workers it already constructed.
580
+ */
581
+ buildRecordedWorkers() {
582
+ if (this.workersActivated)
583
+ return;
584
+ this.workersActivated = true;
585
+ for (const [queue, record] of this.queueWorkerRecords) {
586
+ if (!record.workerConnection) {
587
+ if (record.description.isDefaultQueue)
588
+ throw new ActivatingBackgroundWorkersWithoutDefaultWorkerConnection();
589
+ throw new ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection(record.description.configuredName);
590
+ }
591
+ for (let i = 0; i < record.workerCount; i++) {
592
+ this._workers.push(new Background.Worker(queue.name, this.processorFor(queue), {
593
+ ...record.workerOptions,
594
+ connection: record.workerConnection,
595
+ }));
596
+ }
597
+ }
598
+ }
599
+ /**
600
+ * @internal
601
+ *
602
+ * throws {@link NamedWorkstreamRateLimitMissingMaxOrDuration} for the first
603
+ * workstream whose `rateLimit` lacks a positive integer `max` or `duration`.
604
+ * The type requires both but admits any number, and open-source BullMQ does
605
+ * not validate a `limiter`: a fractional `duration` floors to 0ms and rate
606
+ * limits nothing, and one past Redis's integer range fails every job fetch.
607
+ */
608
+ assertNamedWorkstreamRateLimits(namedWorkstreams, transitional) {
609
+ for (const { name, rateLimit } of namedWorkstreams ?? []) {
610
+ if (!rateLimit)
611
+ continue;
612
+ for (const field of ['max', 'duration']) {
613
+ const value = rateLimit[field];
614
+ if (!(typeof value === 'number' && Number.isSafeInteger(value) && value > 0))
615
+ throw new NamedWorkstreamRateLimitMissingMaxOrDuration(name, transitional, field, value);
616
+ }
617
+ }
618
+ }
619
+ /**
620
+ * @internal
621
+ *
622
+ * when `err` is a {@link RateLimitedPsychicJob} thrown on a queue whose workers
623
+ * carry no BullMQ `limiter`, returns the misconfiguration error to fail the job
624
+ * with instead; otherwise undefined. Declared as `Error` so the concrete,
625
+ * unexported class stays out of the emitted declarations. Four callers, one
626
+ * across a module boundary in `WorkerTestUtils`.
627
+ */
628
+ misconfiguredRateLimitSignal(err, queue) {
629
+ if (!(err instanceof RateLimitedPsychicJob))
630
+ return;
631
+ const record = this.queueWorkerRecords.get(queue);
632
+ if (!record || record.hasLimiter)
633
+ return;
634
+ return new RateLimitedPsychicJobThrownFromWorkerWithoutLimiter(err, record.description);
635
+ }
636
+ /**
637
+ * @internal
638
+ *
639
+ * the processor every worker on `queue` runs: {@link doWork}, with a
640
+ * {@link RateLimitedPsychicJob} translated into BullMQ's own rate-limit
641
+ * signal — the queue's limiter key is set, pausing every worker on the queue,
642
+ * then `RateLimitError` moves the job back without counting an attempt or
643
+ * emitting `failed`. Thrown where the workers carry no `limiter`, it is
644
+ * instead a misconfiguration: see {@link misconfiguredRateLimitSignal}.
645
+ */
646
+ processorFor(queue) {
647
+ return async (job) => {
648
+ try {
649
+ await this.doWork(job);
650
+ }
651
+ catch (err) {
652
+ const misconfigured = this.misconfiguredRateLimitSignal(err, queue);
653
+ if (misconfigured)
654
+ throw misconfigured;
655
+ if (err instanceof RateLimitedPsychicJob) {
656
+ // a fractional number of seconds rounds up: the field is a lower bound on the pause
657
+ const pauseForSeconds = Math.ceil(err.pauseQueueForSeconds);
658
+ // short of PTTL on the limiter key, nothing else shows that this queue is stalled
659
+ PsychicApp.logWithLevel('warn', `[psychic-workers] pausing queue ${queue.name} for ${pauseForSeconds}s: a job threw RateLimitedPsychicJob`);
660
+ await queue.rateLimit(pauseForSeconds * 1000);
661
+ throw new RateLimitError();
662
+ }
663
+ throw err;
664
+ }
665
+ };
666
+ }
667
+ /**
668
+ * starts background workers: connects if this process has not connected yet,
669
+ * then builds the `Worker` objects for every queue `connect` recorded, and
670
+ * installs the process-level fatal-error and signal handlers that shut them
671
+ * down. An earlier producer-only `connect()` no longer leaves `work()` with
672
+ * nothing to do. A process with no `defaultWorkerConnection` configured throws
673
+ * here rather than returning quietly.
442
674
  */
443
675
  work() {
444
676
  process.on('uncaughtException', (error) => {
@@ -506,7 +738,15 @@ export class Background {
506
738
  throw new Error(`Missing queue for: ${jobConfig.queue?.toString()}`);
507
739
  await queueInstance.upsertJobScheduler(schedulerId, { pattern }, {
508
740
  name: 'BackgroundJobQueueStaticJob',
509
- opts: scheduleOpts,
741
+ // the priority from the service's `backgroundJobConfig`, written where open-source
742
+ // BullMQ reads it. Without it BullMQ enqueues every cron run at no priority at all,
743
+ // into the `wait` list, which it drains before it looks at the prioritized set — so
744
+ // a priority-less cron job is fetched ahead of every prioritized job on its queue.
745
+ opts: {
746
+ ...scheduleOpts,
747
+ priority: scheduleOpts.priority ??
748
+ this.mapPriorityWordToPriorityNumber(this.jobConfigToPriority(jobConfig)),
749
+ },
510
750
  data: {
511
751
  globalName,
512
752
  method,
@@ -602,6 +842,34 @@ export class Background {
602
842
  // set this variable out side of the conditional so that
603
843
  // mismatches will raise exceptions even in tests
604
844
  const queueInstance = this.queueInstance(jobConfig);
845
+ // reads the raw `delaySeconds`, before the truthiness coercion below collapses
846
+ // 0, -0 and NaN into `undefined`, and runs outside the test short-circuit so
847
+ // that mismatches raise even in tests.
848
+ if (jobId !== undefined) {
849
+ const requestedDelay = (delaySeconds ?? NaN) * 1000;
850
+ // an empty string is a `jobId` given rather than omitted, and the
851
+ // deduplication block below would read it as falsy and enqueue an ordinary
852
+ // delayed job with no key and no signal.
853
+ if (jobId === '' ||
854
+ !Number.isFinite(requestedDelay) ||
855
+ Math.abs(requestedDelay) > Number.MAX_SAFE_INTEGER ||
856
+ requestedDelay < MINIMUM_DEBOUNCE_DELAY_MS)
857
+ throw new DebouncedJobRequiresMinimumDelay(jobId, delaySeconds, MINIMUM_DEBOUNCE_DELAY_MS);
858
+ // a legal delay can still be too short for the queue it lands on: the key
859
+ // lifetime is the fastest this `jobId` can produce jobs, and a limiter bounds
860
+ // how fast the queue can start them. Compared against the key lifetime rather
861
+ // than the delay, so a burst that would have been fine is refused too.
862
+ const record = queueInstance ? this.queueWorkerRecords.get(queueInstance) : undefined;
863
+ const limiter = record?.workerOptions.limiter;
864
+ // `connect` validates `max` and `duration` for named workstreams only, so
865
+ // a global or native-mode limiter can be anything the type admits; a
866
+ // cadence derived from one of those is not worth refusing a job over
867
+ if (record && limiter && limiter.max > 0 && limiter.duration > 0 && Number.isFinite(limiter.duration)) {
868
+ const keyLifetime = requestedDelay - DEBOUNCE_KEY_MARGIN_MS;
869
+ if (keyLifetime < limiter.duration / limiter.max)
870
+ throw new DebouncedJobOutpacesRateLimit(jobId, requestedDelay, keyLifetime, limiter, record.description);
871
+ }
872
+ }
605
873
  // if delaySeconds is 0, we will intentionally treat
606
874
  // this as `undefined`
607
875
  const delay = delaySeconds ? delaySeconds * 1000 : undefined;
@@ -612,7 +880,15 @@ export class Background {
612
880
  if (EnvInternal.isTest && workersApp.testInvocation === 'automatic') {
613
881
  const queue = new Background.Queue('TestQueue', { connection: {} });
614
882
  const job = new Job(queue, jobType, jobData, {});
615
- await this.doWork(job);
883
+ try {
884
+ await this.doWork(job);
885
+ }
886
+ catch (err) {
887
+ // the job's real queue (not the throwaway TestQueue) decides whether a
888
+ // RateLimitedPsychicJob is a usable signal or a misconfiguration, exactly
889
+ // as the worker would in production
890
+ throw (queueInstance && this.misconfiguredRateLimitSignal(err, queueInstance)) || err;
891
+ }
616
892
  return;
617
893
  //
618
894
  }
@@ -624,30 +900,36 @@ export class Background {
624
900
  if (delay && jobId) {
625
901
  jobOptions.deduplication = {
626
902
  id: jobId,
627
- ttl: delay,
903
+ // deliberately shorter than the delay: see DEBOUNCE_KEY_MARGIN_MS.
904
+ // `Math.floor` is live — Redis `SET ... PX` rejects a fractional
905
+ // argument, and `{ seconds: 3.0005 }` is fractional. `Math.max(1, …)`
906
+ // cannot fire at the current floor, but a `ttl <= 0` fails
907
+ // `deduplicateJob.lua:32` and sets the deduplication key with no expiry
908
+ // at all, so anyone lowering the floor should revisit this line first.
909
+ ttl: Math.max(1, Math.floor(delay - DEBOUNCE_KEY_MARGIN_MS)),
628
910
  extend: true,
629
911
  replace: true,
630
912
  };
631
913
  }
632
- if (groupId && priority) {
633
- await queueInstance.add(jobType, jobData, {
634
- ...jobOptions,
635
- group: {
636
- ...this.groupIdToGroupConfig(groupId),
637
- priority: this.mapPriorityWordToPriorityNumber(priority),
638
- },
639
- // explicitly typing as JobsOptions because Psychic can't be aware of BullMQ Pro options
640
- });
641
- //
642
- }
643
- else {
644
- await queueInstance.add(jobType, jobData, {
645
- ...jobOptions,
646
- group: this.groupIdToGroupConfig(groupId),
647
- priority: this.mapPriorityWordToPriorityNumber(priority),
648
- // explicitly typing as JobsOptions because Psychic can't be aware of BullMQ Pro options
649
- });
650
- }
914
+ const priorityNumber = this.mapPriorityWordToPriorityNumber(priority);
915
+ const groupConfig = this.groupIdToGroupConfig(groupId);
916
+ // BullMQ Pro group priority, ignored by open-source BullMQ, which orders this job's
917
+ // group against the queue's other groups alongside (not instead of) the job priority
918
+ // below — unverified against an installed Pro build. Open-source `JobsOptions` does
919
+ // not declare `group`, so without this annotation TypeScript checks nothing about the
920
+ // key or its body at the `add` call: spread properties escape excess-property checking.
921
+ const proGroupOption = groupConfig
922
+ ? { group: { ...groupConfig, priority: priorityNumber } }
923
+ : {};
924
+ await queueInstance.add(jobType, jobData, {
925
+ ...jobOptions,
926
+ // open-source BullMQ's job priority, and the only priority it reads. Written on
927
+ // every job, grouped or not: without Pro the `group` key spread in below is stored
928
+ // but never scheduled on, so a priority living only inside it would do nothing —
929
+ // and every named workstream job carries a group id (see `jobConfigToGroupId`).
930
+ priority: priorityNumber,
931
+ ...proGroupOption,
932
+ });
651
933
  }
652
934
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
653
935
  jobConfigToPriority(jobConfig) {
@@ -678,6 +960,9 @@ export class Background {
678
960
  return;
679
961
  return { id: groupId };
680
962
  }
963
+ // a priority is only honoured while the job is fetched from the prioritized set. BullMQ
964
+ // returns a recovered stalled job to `wait`, which a worker drains ahead of the
965
+ // prioritized set, so a job that stalls is re-run ahead of higher-priority work.
681
966
  mapPriorityWordToPriorityNumber(priority) {
682
967
  switch (priority) {
683
968
  case 'urgent':