@rvoh/psychic-workers 2.4.1 → 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.
- package/dist/cjs/src/background/BaseBackgroundedModel.js +112 -19
- package/dist/cjs/src/background/BaseBackgroundedService.js +73 -9
- package/dist/cjs/src/background/BaseScheduledService.js +59 -1
- package/dist/cjs/src/background/index.js +418 -105
- package/dist/cjs/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
- package/dist/cjs/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
- package/dist/cjs/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
- package/dist/cjs/src/error/background/RateLimitedPsychicJob.js +82 -0
- package/dist/cjs/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
- package/dist/cjs/src/package-exports/errors.js +7 -0
- package/dist/cjs/src/test-utils/WorkerTestUtils.js +79 -14
- package/dist/esm/src/background/BaseBackgroundedModel.js +112 -19
- package/dist/esm/src/background/BaseBackgroundedService.js +73 -9
- package/dist/esm/src/background/BaseScheduledService.js +59 -1
- package/dist/esm/src/background/index.js +418 -105
- package/dist/esm/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
- package/dist/esm/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
- package/dist/esm/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
- package/dist/esm/src/error/background/RateLimitedPsychicJob.js +82 -0
- package/dist/esm/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
- package/dist/esm/src/package-exports/errors.js +7 -0
- package/dist/esm/src/test-utils/WorkerTestUtils.js +79 -14
- package/dist/types/src/background/BaseBackgroundedModel.d.ts +97 -20
- package/dist/types/src/background/BaseBackgroundedService.d.ts +61 -10
- package/dist/types/src/background/BaseScheduledService.d.ts +54 -1
- package/dist/types/src/background/index.d.ts +137 -5
- package/dist/types/src/error/background/DebouncedJobOutpacesRateLimit.d.ts +31 -0
- package/dist/types/src/error/background/DebouncedJobRequiresMinimumDelay.d.ts +23 -0
- package/dist/types/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.d.ts +16 -0
- package/dist/types/src/error/background/RateLimitedPsychicJob.d.ts +73 -0
- package/dist/types/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.d.ts +16 -0
- package/dist/types/src/package-exports/errors.d.ts +1 -0
- package/dist/types/src/psychic-app-workers/index.d.ts +102 -53
- package/dist/types/src/test-utils/WorkerTestUtils.d.ts +32 -1
- package/dist/types/src/types/background.d.ts +100 -4
- package/package.json +9 -8
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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, {
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
*
|
|
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) => {
|
|
@@ -500,19 +732,21 @@ export class Background {
|
|
|
500
732
|
*/
|
|
501
733
|
async scheduledMethod(ObjectClass, pattern, method, { globalName, args = [], jobConfig = {}, scheduleOpts = {}, }) {
|
|
502
734
|
this.connect();
|
|
503
|
-
|
|
504
|
-
// Since the name is really a job type and never changes, the `jobId` is the only
|
|
505
|
-
// way to allow multiple jobs with the same cron repeat pattern. Uniqueness will
|
|
506
|
-
// now be enforced by combining class name, method name, and cron repeat pattern.
|
|
507
|
-
//
|
|
508
|
-
// See: https://docs.bullmq.io/guide/jobs/repeatable
|
|
509
|
-
const schedulerId = `${globalName}:${method}`;
|
|
735
|
+
const schedulerId = this.jobSchedulerId(globalName, method);
|
|
510
736
|
const queueInstance = this.queueInstance(jobConfig);
|
|
511
737
|
if (!queueInstance)
|
|
512
738
|
throw new Error(`Missing queue for: ${jobConfig.queue?.toString()}`);
|
|
513
739
|
await queueInstance.upsertJobScheduler(schedulerId, { pattern }, {
|
|
514
740
|
name: 'BackgroundJobQueueStaticJob',
|
|
515
|
-
|
|
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
|
+
},
|
|
516
750
|
data: {
|
|
517
751
|
globalName,
|
|
518
752
|
method,
|
|
@@ -520,6 +754,40 @@ export class Background {
|
|
|
520
754
|
},
|
|
521
755
|
});
|
|
522
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* @internal
|
|
759
|
+
*
|
|
760
|
+
* Returns the id a scheduled job is registered with BullMQ under.
|
|
761
|
+
*
|
|
762
|
+
* `jobId` is used to determine uniqueness along with name and repeat pattern.
|
|
763
|
+
* Since the name is really a job type and never changes, the `jobId` is the only
|
|
764
|
+
* way to allow multiple jobs with the same cron repeat pattern. Uniqueness is
|
|
765
|
+
* enforced by combining the global name and the method name.
|
|
766
|
+
*
|
|
767
|
+
* See: https://docs.bullmq.io/guide/jobs/repeatable
|
|
768
|
+
*
|
|
769
|
+
* @param globalName - the globalName of the class the method belongs to
|
|
770
|
+
* @param method - the name of the scheduled method
|
|
771
|
+
*/
|
|
772
|
+
jobSchedulerId(globalName, method) {
|
|
773
|
+
return `${globalName}:${method}`;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* removes a scheduled job from BullMQ, preventing it from being run again
|
|
777
|
+
*
|
|
778
|
+
* Every queue is checked, rather than the one the job's current
|
|
779
|
+
* `backgroundJobConfig` routes to, so that a job can still be unscheduled
|
|
780
|
+
* after its workstream has changed, or after the class that scheduled it has
|
|
781
|
+
* been deleted.
|
|
782
|
+
*
|
|
783
|
+
* @param jobSchedulerId - the id the job was scheduled under
|
|
784
|
+
* @returns true if a scheduled job was removed, false if none was found
|
|
785
|
+
*/
|
|
786
|
+
async unschedule(jobSchedulerId) {
|
|
787
|
+
this.connect();
|
|
788
|
+
const removed = await Promise.all(this.queues.map(queue => queue.removeJobScheduler(jobSchedulerId)));
|
|
789
|
+
return removed.some(Boolean);
|
|
790
|
+
}
|
|
523
791
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
524
792
|
queueInstance(values) {
|
|
525
793
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -574,6 +842,34 @@ export class Background {
|
|
|
574
842
|
// set this variable out side of the conditional so that
|
|
575
843
|
// mismatches will raise exceptions even in tests
|
|
576
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
|
+
}
|
|
577
873
|
// if delaySeconds is 0, we will intentionally treat
|
|
578
874
|
// this as `undefined`
|
|
579
875
|
const delay = delaySeconds ? delaySeconds * 1000 : undefined;
|
|
@@ -584,7 +880,15 @@ export class Background {
|
|
|
584
880
|
if (EnvInternal.isTest && workersApp.testInvocation === 'automatic') {
|
|
585
881
|
const queue = new Background.Queue('TestQueue', { connection: {} });
|
|
586
882
|
const job = new Job(queue, jobType, jobData, {});
|
|
587
|
-
|
|
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
|
+
}
|
|
588
892
|
return;
|
|
589
893
|
//
|
|
590
894
|
}
|
|
@@ -596,30 +900,36 @@ export class Background {
|
|
|
596
900
|
if (delay && jobId) {
|
|
597
901
|
jobOptions.deduplication = {
|
|
598
902
|
id: jobId,
|
|
599
|
-
|
|
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)),
|
|
600
910
|
extend: true,
|
|
601
911
|
replace: true,
|
|
602
912
|
};
|
|
603
913
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
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
|
+
});
|
|
623
933
|
}
|
|
624
934
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
625
935
|
jobConfigToPriority(jobConfig) {
|
|
@@ -650,6 +960,9 @@ export class Background {
|
|
|
650
960
|
return;
|
|
651
961
|
return { id: groupId };
|
|
652
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.
|
|
653
966
|
mapPriorityWordToPriorityNumber(priority) {
|
|
654
967
|
switch (priority) {
|
|
655
968
|
case 'urgent':
|