@alexify/migronaut 2.2.0 → 2.4.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/CHANGELOG.md +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/src/bullmq/service.js
CHANGED
|
@@ -4,18 +4,32 @@ const { errorText } = require('../utils/error.js');
|
|
|
4
4
|
const { isBareFilename } = require('../utils/migration-name.js');
|
|
5
5
|
const { redactDeep, redactOutbound } = require('../utils/redact.js');
|
|
6
6
|
const {
|
|
7
|
+
DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
|
|
7
8
|
DEFAULT_CONVERGE_SCHEDULER_ID,
|
|
8
9
|
DEFAULT_QUEUE_NAME,
|
|
9
10
|
DEFAULT_SCHEDULER_ID,
|
|
10
11
|
JOB_NAMES,
|
|
12
|
+
backgroundQueueName,
|
|
13
|
+
buildBackgroundVerifyJobTemplate,
|
|
11
14
|
buildConvergeJobTemplate,
|
|
12
15
|
permissionsNeeded,
|
|
13
16
|
resolveAllow,
|
|
14
17
|
buildSyncJobTemplate,
|
|
15
|
-
|
|
18
|
+
isObjectLike,
|
|
16
19
|
} = require('./jobs.js');
|
|
20
|
+
const {
|
|
21
|
+
createBackgroundProcessor,
|
|
22
|
+
resolveBackgroundProcessorOptions,
|
|
23
|
+
} = require('./background-processor.js');
|
|
17
24
|
const { createMigrationProcessor, resolveProcessorOptions } = require('./processor.js');
|
|
18
|
-
const {
|
|
25
|
+
const {
|
|
26
|
+
DEFAULT_STALL_MS,
|
|
27
|
+
assertJobOptions,
|
|
28
|
+
enqueueBackground,
|
|
29
|
+
enqueueConverge,
|
|
30
|
+
enqueueDown,
|
|
31
|
+
enqueueUp,
|
|
32
|
+
} = require('./producer.js');
|
|
19
33
|
|
|
20
34
|
/**
|
|
21
35
|
* Twice BullMQ's default job lock: its renewal (every half) then survives a
|
|
@@ -27,6 +41,98 @@ const DEFAULT_LOCK_DURATION_MS = 60_000;
|
|
|
27
41
|
const DEFAULT_MAX_STALLED_COUNT = 1;
|
|
28
42
|
/** The shortest interval `schedule({ every })` accepts */
|
|
29
43
|
const MIN_SCHEDULE_EVERY_MS = 1000;
|
|
44
|
+
/** Background jobs run side by side — a lane and a coordinator need not take turns */
|
|
45
|
+
const DEFAULT_BACKGROUND_CONCURRENCY = 2;
|
|
46
|
+
/** How often the drift watch runs on the background queue by default */
|
|
47
|
+
const DEFAULT_BACKGROUND_VERIFY_MS = 600_000;
|
|
48
|
+
/** Every key the `background` option accepts */
|
|
49
|
+
const BACKGROUND_KEYS = new Set([
|
|
50
|
+
'queueName',
|
|
51
|
+
'queue',
|
|
52
|
+
'jobOptions',
|
|
53
|
+
'workerOptions',
|
|
54
|
+
'sliceMs',
|
|
55
|
+
'children',
|
|
56
|
+
'pollIntervalMs',
|
|
57
|
+
'stallMs',
|
|
58
|
+
'verifyIntervalMs',
|
|
59
|
+
'watch',
|
|
60
|
+
'maxLaneRetries',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `background` option, checked and filled in — or undefined when the
|
|
65
|
+
* queue has no background side. `true` takes every default.
|
|
66
|
+
*/
|
|
67
|
+
function resolveBackground(background, { queueName, QueueSource }) {
|
|
68
|
+
if (background === undefined || background === false) return undefined;
|
|
69
|
+
const options = background === true ? {} : background;
|
|
70
|
+
if (!isObjectLike(options)) {
|
|
71
|
+
throw new ConfigInvalidError('background must be true or an object');
|
|
72
|
+
}
|
|
73
|
+
for (const key of Object.keys(options)) {
|
|
74
|
+
if (!BACKGROUND_KEYS.has(key)) {
|
|
75
|
+
throw new ConfigInvalidError(`background.${key} is not a known option`, { key });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const queueIsInstance =
|
|
79
|
+
options.queue !== undefined &&
|
|
80
|
+
isObjectLike(options.queue) &&
|
|
81
|
+
typeof options.queue.addBulk === 'function';
|
|
82
|
+
if (options.queue !== undefined && !queueIsInstance) {
|
|
83
|
+
throw new ConfigInvalidError('background.queue must be a Queue instance');
|
|
84
|
+
}
|
|
85
|
+
if (!queueIsInstance && !isClass(QueueSource)) {
|
|
86
|
+
throw new ConfigInvalidError(
|
|
87
|
+
'background needs bullmq.Queue as a class to build its queue — or a background.queue',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const name =
|
|
91
|
+
options.queueName ??
|
|
92
|
+
(queueIsInstance ? options.queue.name : undefined) ??
|
|
93
|
+
backgroundQueueName(queueName);
|
|
94
|
+
assertName(name, 'background.queueName');
|
|
95
|
+
if (name === queueName) {
|
|
96
|
+
throw new ConfigInvalidError('background.queueName must differ from the migration queue', {
|
|
97
|
+
queueName: name,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (options.workerOptions !== undefined && !isObjectLike(options.workerOptions)) {
|
|
101
|
+
throw new ConfigInvalidError('background.workerOptions must be an object');
|
|
102
|
+
}
|
|
103
|
+
assertBackgroundConcurrency(options.workerOptions?.concurrency);
|
|
104
|
+
const verifyIntervalMs = options.verifyIntervalMs ?? DEFAULT_BACKGROUND_VERIFY_MS;
|
|
105
|
+
if (
|
|
106
|
+
verifyIntervalMs !== false &&
|
|
107
|
+
(!Number.isSafeInteger(verifyIntervalMs) || verifyIntervalMs < MIN_SCHEDULE_EVERY_MS)
|
|
108
|
+
) {
|
|
109
|
+
throw new ConfigInvalidError(
|
|
110
|
+
`background.verifyIntervalMs must be false or an integer ≥ ${MIN_SCHEDULE_EVERY_MS}`,
|
|
111
|
+
{ verifyIntervalMs },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const { watch } = options;
|
|
115
|
+
if (watch !== undefined && typeof watch !== 'boolean' && !isObjectLike(watch)) {
|
|
116
|
+
throw new ConfigInvalidError('background.watch must be a boolean or the watcher options');
|
|
117
|
+
}
|
|
118
|
+
// Said explicitly, the interval is re-registered at every start; left to its
|
|
119
|
+
// default, a schedule set with schedule({ job: 'background-verify' }) stays.
|
|
120
|
+
return {
|
|
121
|
+
...options,
|
|
122
|
+
name,
|
|
123
|
+
queueIsInstance,
|
|
124
|
+
verifyIntervalMs,
|
|
125
|
+
verifyIntervalGiven: options.verifyIntervalMs !== undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertBackgroundConcurrency(concurrency) {
|
|
130
|
+
if (concurrency !== undefined && (!Number.isSafeInteger(concurrency) || concurrency < 1)) {
|
|
131
|
+
throw new ConfigInvalidError('background worker concurrency must be a positive integer', {
|
|
132
|
+
concurrency,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
30
136
|
|
|
31
137
|
const isClass = (value) => typeof value === 'function';
|
|
32
138
|
|
|
@@ -90,9 +196,16 @@ class MigrationQueue {
|
|
|
90
196
|
#globalConcurrency;
|
|
91
197
|
#allow;
|
|
92
198
|
#closing;
|
|
199
|
+
#background;
|
|
200
|
+
#backgroundQueue;
|
|
201
|
+
#ownsBackgroundQueue = false;
|
|
202
|
+
#backgroundProcessor;
|
|
203
|
+
#backgroundWorker;
|
|
204
|
+
#backgroundStarting;
|
|
205
|
+
#backgroundWatcher;
|
|
93
206
|
|
|
94
207
|
constructor(options) {
|
|
95
|
-
if (!
|
|
208
|
+
if (!isObjectLike(options)) {
|
|
96
209
|
throw new ConfigInvalidError('createMigrationQueue options must be an object');
|
|
97
210
|
}
|
|
98
211
|
const {
|
|
@@ -108,9 +221,11 @@ class MigrationQueue {
|
|
|
108
221
|
globalConcurrency = true,
|
|
109
222
|
lockWait,
|
|
110
223
|
allow,
|
|
224
|
+
background,
|
|
225
|
+
userlandLogRows,
|
|
111
226
|
} = options;
|
|
112
227
|
|
|
113
|
-
if (!
|
|
228
|
+
if (!isObjectLike(bullmq)) {
|
|
114
229
|
throw new ConfigInvalidError(
|
|
115
230
|
'bullmq is required — pass { Queue, Worker, QueueEvents } from your own bullmq install',
|
|
116
231
|
);
|
|
@@ -125,14 +240,14 @@ class MigrationQueue {
|
|
|
125
240
|
{ telemetry: typeof telemetry },
|
|
126
241
|
);
|
|
127
242
|
}
|
|
128
|
-
const queueIsInstance =
|
|
243
|
+
const queueIsInstance = isObjectLike(Queue) && typeof Queue.addBulk === 'function';
|
|
129
244
|
if (!isClass(Queue) && !queueIsInstance) {
|
|
130
245
|
throw new ConfigInvalidError('bullmq.Queue must be the Queue class or a Queue instance');
|
|
131
246
|
}
|
|
132
247
|
if (Worker !== undefined && !isClass(Worker)) {
|
|
133
248
|
throw new ConfigInvalidError('bullmq.Worker must be the Worker class');
|
|
134
249
|
}
|
|
135
|
-
const eventsIsInstance =
|
|
250
|
+
const eventsIsInstance = isObjectLike(QueueEvents) && typeof QueueEvents.on === 'function';
|
|
136
251
|
if (QueueEvents !== undefined && !isClass(QueueEvents) && !eventsIsInstance) {
|
|
137
252
|
throw new ConfigInvalidError(
|
|
138
253
|
'bullmq.QueueEvents must be the QueueEvents class or a QueueEvents instance',
|
|
@@ -155,13 +270,18 @@ class MigrationQueue {
|
|
|
155
270
|
const resolvedPrefix = prefix ?? (queueIsInstance ? Queue.opts?.prefix : undefined);
|
|
156
271
|
if (resolvedPrefix !== undefined) assertName(resolvedPrefix, 'prefix');
|
|
157
272
|
if (queueIsInstance) {
|
|
158
|
-
MigrationQueue.#assertSameQueue('Queue', Queue, resolvedName, resolvedPrefix);
|
|
273
|
+
MigrationQueue.#assertSameQueue('bullmq.Queue', Queue, resolvedName, resolvedPrefix);
|
|
159
274
|
}
|
|
160
275
|
if (eventsIsInstance) {
|
|
161
|
-
MigrationQueue.#assertSameQueue(
|
|
276
|
+
MigrationQueue.#assertSameQueue(
|
|
277
|
+
'bullmq.QueueEvents',
|
|
278
|
+
QueueEvents,
|
|
279
|
+
resolvedName,
|
|
280
|
+
resolvedPrefix,
|
|
281
|
+
);
|
|
162
282
|
}
|
|
163
283
|
assertJobOptions(jobOptions);
|
|
164
|
-
if (!
|
|
284
|
+
if (!isObjectLike(workerOptions)) {
|
|
165
285
|
throw new ConfigInvalidError('workerOptions must be an object');
|
|
166
286
|
}
|
|
167
287
|
MigrationQueue.#assertConcurrency(workerOptions.concurrency);
|
|
@@ -188,8 +308,30 @@ class MigrationQueue {
|
|
|
188
308
|
...(config !== undefined ? { config } : {}),
|
|
189
309
|
...(lockWait !== undefined ? { lockWait } : {}),
|
|
190
310
|
...(allow !== undefined ? { allow } : {}),
|
|
311
|
+
...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
|
|
191
312
|
});
|
|
192
313
|
this.#allow = resolveAllow(allow);
|
|
314
|
+
const backgroundSettings = resolveBackground(background, {
|
|
315
|
+
queueName: resolvedName,
|
|
316
|
+
QueueSource: Queue,
|
|
317
|
+
});
|
|
318
|
+
if (backgroundSettings?.queueIsInstance) {
|
|
319
|
+
// The same check as the migration queue's: a Worker built here on
|
|
320
|
+
// another name or prefix would listen to an empty queue.
|
|
321
|
+
MigrationQueue.#assertSameQueue(
|
|
322
|
+
'background.queue',
|
|
323
|
+
backgroundSettings.queue,
|
|
324
|
+
backgroundSettings.name,
|
|
325
|
+
resolvedPrefix,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (backgroundSettings !== undefined) {
|
|
329
|
+
// Validated against a stand-in queue: the real one does not exist yet.
|
|
330
|
+
resolveBackgroundProcessorOptions({
|
|
331
|
+
...MigrationQueue.#backgroundProcessorOptions(backgroundSettings),
|
|
332
|
+
queue: { addBulk() {} },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
193
335
|
|
|
194
336
|
this.#ownsKit = kit === undefined;
|
|
195
337
|
this.#kit = kit ?? new MigratorKit(config ?? {}, kitOptions);
|
|
@@ -207,6 +349,29 @@ class MigrationQueue {
|
|
|
207
349
|
error: errorText(error),
|
|
208
350
|
}),
|
|
209
351
|
);
|
|
352
|
+
if (backgroundSettings !== undefined) {
|
|
353
|
+
this.#background = backgroundSettings;
|
|
354
|
+
this.#ownsBackgroundQueue = !backgroundSettings.queueIsInstance;
|
|
355
|
+
this.#backgroundQueue = backgroundSettings.queueIsInstance
|
|
356
|
+
? backgroundSettings.queue
|
|
357
|
+
: new Queue(backgroundSettings.name, {
|
|
358
|
+
connection,
|
|
359
|
+
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
|
|
360
|
+
...(telemetry !== undefined ? { telemetry } : {}),
|
|
361
|
+
});
|
|
362
|
+
this.#listen(this.#backgroundQueue, 'error', (error) =>
|
|
363
|
+
this.#kit.logger.error(`✖ Background queue error: ${errorText(error)}`, {
|
|
364
|
+
queue: backgroundSettings.name,
|
|
365
|
+
error: errorText(error),
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
this.#backgroundProcessor = createBackgroundProcessor({
|
|
369
|
+
kit: this.#kit,
|
|
370
|
+
queue: this.#backgroundQueue,
|
|
371
|
+
...MigrationQueue.#backgroundProcessorOptions(backgroundSettings),
|
|
372
|
+
...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
210
375
|
this.#processor = createMigrationProcessor({
|
|
211
376
|
kit: this.#kit,
|
|
212
377
|
// `sync` jobs (and the converge jobs they add) enqueue into the queue
|
|
@@ -215,9 +380,64 @@ class MigrationQueue {
|
|
|
215
380
|
...(lockWait !== undefined ? { lockWait } : {}),
|
|
216
381
|
...(jobOptions !== undefined ? { jobOptions } : {}),
|
|
217
382
|
...(allow !== undefined ? { allow } : {}),
|
|
383
|
+
...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
|
|
384
|
+
// What an `up` registers starts on the background queue at once.
|
|
385
|
+
...(this.#backgroundQueue !== undefined
|
|
386
|
+
? {
|
|
387
|
+
background: {
|
|
388
|
+
queue: this.#backgroundQueue,
|
|
389
|
+
...MigrationQueue.#backgroundEnqueueOptions(backgroundSettings),
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
: {}),
|
|
218
393
|
});
|
|
219
394
|
}
|
|
220
395
|
|
|
396
|
+
/** Whether the drift watch's schedule exists already — false when that cannot be told */
|
|
397
|
+
static async #hasScheduler(queue) {
|
|
398
|
+
if (typeof queue.getJobScheduler === 'function') {
|
|
399
|
+
return Boolean(await queue.getJobScheduler(DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID));
|
|
400
|
+
}
|
|
401
|
+
if (typeof queue.getJobSchedulers === 'function') {
|
|
402
|
+
for (const scheduler of await queue.getJobSchedulers()) {
|
|
403
|
+
if ((scheduler.id ?? scheduler.key) === DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID) return true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The background processor's options out of the resolved `background` option */
|
|
410
|
+
static #backgroundProcessorOptions(settings) {
|
|
411
|
+
const picked = {};
|
|
412
|
+
for (const key of [
|
|
413
|
+
'jobOptions',
|
|
414
|
+
'sliceMs',
|
|
415
|
+
'children',
|
|
416
|
+
'pollIntervalMs',
|
|
417
|
+
'stallMs',
|
|
418
|
+
'maxLaneRetries',
|
|
419
|
+
]) {
|
|
420
|
+
if (settings[key] !== undefined) picked[key] = settings[key];
|
|
421
|
+
}
|
|
422
|
+
return picked;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** What every coordinator enqueue from this object carries */
|
|
426
|
+
static #backgroundEnqueueOptions(settings) {
|
|
427
|
+
return {
|
|
428
|
+
...(settings.jobOptions !== undefined ? { jobOptions: settings.jobOptions } : {}),
|
|
429
|
+
stallMs: settings.stallMs ?? DEFAULT_STALL_MS,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#assertBackground(method) {
|
|
434
|
+
if (this.#background === undefined) {
|
|
435
|
+
throw new ConfigInvalidError(
|
|
436
|
+
`${method} needs the background queue — pass background: true to createMigrationQueue`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
221
441
|
/**
|
|
222
442
|
* Refuse, at the enqueue call, a request this object's own policy would
|
|
223
443
|
* refuse on the worker — a job that can only fail is better not added. The
|
|
@@ -238,19 +458,15 @@ class MigrationQueue {
|
|
|
238
458
|
/** An injected instance must be on the queue this object is configured for */
|
|
239
459
|
static #assertSameQueue(label, instance, name, prefix) {
|
|
240
460
|
if (typeof instance.name === 'string' && instance.name !== name) {
|
|
241
|
-
throw new ConfigInvalidError(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
queueName: name,
|
|
245
|
-
},
|
|
246
|
-
);
|
|
461
|
+
throw new ConfigInvalidError(`${label} is on queue "${instance.name}", not "${name}"`, {
|
|
462
|
+
queueName: name,
|
|
463
|
+
});
|
|
247
464
|
}
|
|
248
465
|
const instancePrefix = instance.opts?.prefix;
|
|
249
466
|
if (instancePrefix !== undefined && prefix !== undefined && instancePrefix !== prefix) {
|
|
250
|
-
throw new ConfigInvalidError(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
);
|
|
467
|
+
throw new ConfigInvalidError(`${label} uses prefix "${instancePrefix}", not "${prefix}"`, {
|
|
468
|
+
prefix,
|
|
469
|
+
});
|
|
254
470
|
}
|
|
255
471
|
}
|
|
256
472
|
|
|
@@ -301,6 +517,26 @@ class MigrationQueue {
|
|
|
301
517
|
return this.#processor;
|
|
302
518
|
}
|
|
303
519
|
|
|
520
|
+
/** The background queue (`background` option), if any */
|
|
521
|
+
get backgroundQueue() {
|
|
522
|
+
return this.#backgroundQueue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The background worker started by {@link startBackgroundWorker}, if any */
|
|
526
|
+
get backgroundWorker() {
|
|
527
|
+
return this.#backgroundWorker;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The background queue's processor, for a Worker you construct yourself */
|
|
531
|
+
get backgroundProcessor() {
|
|
532
|
+
return this.#backgroundProcessor;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The live drift watcher `startBackgroundWorker()` started, if any */
|
|
536
|
+
get backgroundWatcher() {
|
|
537
|
+
return this.#backgroundWatcher;
|
|
538
|
+
}
|
|
539
|
+
|
|
304
540
|
#ensureQueueEvents() {
|
|
305
541
|
if (this.#queueEvents) return this.#queueEvents;
|
|
306
542
|
// A connection opened after close() would have nobody to close it.
|
|
@@ -381,6 +617,48 @@ class MigrationQueue {
|
|
|
381
617
|
);
|
|
382
618
|
}
|
|
383
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Enqueue the coordinator of one background migration — or of every one with
|
|
622
|
+
* work to do. Idempotent: a coordinator already alive absorbs the add.
|
|
623
|
+
* @experimental
|
|
624
|
+
*/
|
|
625
|
+
async enqueueBackground(name, options = {}) {
|
|
626
|
+
this.#assertOpen();
|
|
627
|
+
this.#assertBackground('enqueueBackground()');
|
|
628
|
+
if (!isObjectLike(options)) {
|
|
629
|
+
throw new ConfigInvalidError('enqueueBackground options must be an object');
|
|
630
|
+
}
|
|
631
|
+
return enqueueBackground(this.#backgroundQueue, this.#kit, {
|
|
632
|
+
...MigrationQueue.#backgroundEnqueueOptions(this.#background),
|
|
633
|
+
...options,
|
|
634
|
+
...(name !== undefined ? { migration: name } : {}),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** A background migration's status (or every one's) — read from MongoDB */
|
|
639
|
+
async backgroundStatus(name) {
|
|
640
|
+
this.#assertOpen();
|
|
641
|
+
return this.#kit.backgroundStatus(name);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The drift watch, now — and, on a queue with a background side, a
|
|
646
|
+
* coordinator for whatever it reopened.
|
|
647
|
+
* @experimental
|
|
648
|
+
*/
|
|
649
|
+
async verifyBackground(options = {}) {
|
|
650
|
+
this.#assertOpen();
|
|
651
|
+
const result = await this.#kit.verifyBackground(options);
|
|
652
|
+
if (this.#background !== undefined && result.drift.length > 0) {
|
|
653
|
+
await enqueueBackground(
|
|
654
|
+
this.#backgroundQueue,
|
|
655
|
+
this.#kit,
|
|
656
|
+
MigrationQueue.#backgroundEnqueueOptions(this.#background),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
|
|
384
662
|
/** Full migration status — read straight from MongoDB, not from the queue */
|
|
385
663
|
async status() {
|
|
386
664
|
this.#assertOpen();
|
|
@@ -414,7 +692,7 @@ class MigrationQueue {
|
|
|
414
692
|
*/
|
|
415
693
|
async startWorker(overrides = {}) {
|
|
416
694
|
this.#assertOpen();
|
|
417
|
-
if (!
|
|
695
|
+
if (!isObjectLike(overrides)) {
|
|
418
696
|
throw new ConfigInvalidError('startWorker options must be an object');
|
|
419
697
|
}
|
|
420
698
|
MigrationQueue.#assertConcurrency(overrides.concurrency);
|
|
@@ -481,6 +759,117 @@ class MigrationQueue {
|
|
|
481
759
|
return worker;
|
|
482
760
|
}
|
|
483
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Start the background worker: coordinators and lanes of background
|
|
764
|
+
* migrations, side by side (concurrency 2 by default). Connects to MongoDB
|
|
765
|
+
* first, registers the drift watch's schedule (`verifyIntervalMs`), and
|
|
766
|
+
* heals — a coordinator for every background migration with work to do.
|
|
767
|
+
* Calling it again returns the same worker.
|
|
768
|
+
* @experimental
|
|
769
|
+
*/
|
|
770
|
+
async startBackgroundWorker(overrides = {}) {
|
|
771
|
+
this.#assertOpen();
|
|
772
|
+
this.#assertBackground('startBackgroundWorker()');
|
|
773
|
+
if (!isObjectLike(overrides)) {
|
|
774
|
+
throw new ConfigInvalidError('startBackgroundWorker options must be an object');
|
|
775
|
+
}
|
|
776
|
+
assertBackgroundConcurrency(overrides.concurrency);
|
|
777
|
+
if (!this.#WorkerClass) {
|
|
778
|
+
throw new ConfigInvalidError(
|
|
779
|
+
'startBackgroundWorker() needs the Worker class — pass bullmq: { Queue, Worker }',
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
this.#backgroundStarting ??= this.#startBackgroundWorker(overrides).catch((error) => {
|
|
783
|
+
this.#backgroundStarting = undefined;
|
|
784
|
+
throw error;
|
|
785
|
+
});
|
|
786
|
+
return this.#backgroundStarting;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async #startBackgroundWorker(overrides) {
|
|
790
|
+
await this.#kit.connect();
|
|
791
|
+
const queue = this.#backgroundQueue;
|
|
792
|
+
const { verifyIntervalMs, verifyIntervalGiven, workerOptions = {} } = this.#background;
|
|
793
|
+
if (
|
|
794
|
+
verifyIntervalMs !== false &&
|
|
795
|
+
typeof queue.upsertJobScheduler === 'function' &&
|
|
796
|
+
(verifyIntervalGiven || !(await MigrationQueue.#hasScheduler(queue)))
|
|
797
|
+
) {
|
|
798
|
+
await queue.upsertJobScheduler(
|
|
799
|
+
DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
|
|
800
|
+
{ every: verifyIntervalMs },
|
|
801
|
+
buildBackgroundVerifyJobTemplate({ jobOptions: this.#background.jobOptions }),
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
this.#assertOpen();
|
|
805
|
+
const Worker = this.#WorkerClass;
|
|
806
|
+
// An injected background queue says where its keys live; its worker listens there.
|
|
807
|
+
const prefix = this.#background.queue?.opts?.prefix ?? this.#prefix;
|
|
808
|
+
const worker = new Worker(this.#background.name, this.#backgroundProcessor, {
|
|
809
|
+
connection: this.#connection,
|
|
810
|
+
...(prefix !== undefined ? { prefix } : {}),
|
|
811
|
+
lockDuration: DEFAULT_LOCK_DURATION_MS,
|
|
812
|
+
maxStalledCount: DEFAULT_MAX_STALLED_COUNT,
|
|
813
|
+
...(this.#telemetry !== undefined ? { telemetry: this.#telemetry } : {}),
|
|
814
|
+
concurrency: DEFAULT_BACKGROUND_CONCURRENCY,
|
|
815
|
+
...workerOptions,
|
|
816
|
+
...overrides,
|
|
817
|
+
});
|
|
818
|
+
this.#backgroundWorker = worker;
|
|
819
|
+
const fields = { queue: this.#background.name };
|
|
820
|
+
this.#listen(worker, 'error', (error) =>
|
|
821
|
+
this.#kit.logger.error(`✖ Background worker error: ${errorText(error)}`, {
|
|
822
|
+
...fields,
|
|
823
|
+
error: errorText(error),
|
|
824
|
+
}),
|
|
825
|
+
);
|
|
826
|
+
this.#listen(worker, 'failed', (job, error) =>
|
|
827
|
+
this.#kit.logger.warn(
|
|
828
|
+
`✖ Background job failed${job?.id !== undefined ? ` (${job.id})` : ''}: ${errorText(error)}`,
|
|
829
|
+
{ ...fields, ...failedJobFields(job, error), error: errorText(error) },
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
await worker.waitUntilReady?.();
|
|
833
|
+
// Closing meanwhile: close() takes it from here — nothing more to start.
|
|
834
|
+
if (this.#closing) return worker;
|
|
835
|
+
await this.#backgroundProcessor.heal();
|
|
836
|
+
if (this.#closing) return worker;
|
|
837
|
+
await this.#startWatcher();
|
|
838
|
+
return worker;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The live drift watcher, in this process — when `background.watch` says
|
|
843
|
+
* so, or, unsaid, when `backgroundDrift` is `'stream'` or `'both'`. A
|
|
844
|
+
* watcher that cannot start (a standalone server) leaves the polling watch
|
|
845
|
+
* to it, with a warning: the worker itself is fine.
|
|
846
|
+
*/
|
|
847
|
+
async #startWatcher() {
|
|
848
|
+
let wanted = this.#background.watch;
|
|
849
|
+
wanted ??= (await this.#kit.driftMode()) !== 'poll';
|
|
850
|
+
if (wanted === false) return;
|
|
851
|
+
const logger = this.#kit.logger;
|
|
852
|
+
try {
|
|
853
|
+
this.#backgroundWatcher = await this.#kit.watchBackground({
|
|
854
|
+
...(typeof wanted === 'object' ? wanted : {}),
|
|
855
|
+
onError: (error, collection) =>
|
|
856
|
+
logger.warn(
|
|
857
|
+
`⚠ Drift watcher${collection ? ` (${collection})` : ''}: ${errorText(error)}`,
|
|
858
|
+
{
|
|
859
|
+
queue: this.#background.name,
|
|
860
|
+
...(collection ? { collection } : {}),
|
|
861
|
+
error: errorText(error),
|
|
862
|
+
},
|
|
863
|
+
),
|
|
864
|
+
});
|
|
865
|
+
} catch (error) {
|
|
866
|
+
logger.warn(`⚠ The live drift watcher did not start: ${errorText(error)}`, {
|
|
867
|
+
queue: this.#background.name,
|
|
868
|
+
error: errorText(error),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
484
873
|
/** Stop workers from picking up new jobs. The job in flight finishes */
|
|
485
874
|
async pause() {
|
|
486
875
|
this.#assertOpen();
|
|
@@ -532,17 +921,32 @@ class MigrationQueue {
|
|
|
532
921
|
*/
|
|
533
922
|
async schedule(options = {}) {
|
|
534
923
|
this.#assertOpen();
|
|
535
|
-
if (!
|
|
924
|
+
if (!isObjectLike(options)) {
|
|
536
925
|
throw new ConfigInvalidError('schedule options must be an object');
|
|
537
926
|
}
|
|
538
927
|
const { job = JOB_NAMES.SYNC, every, pattern, tz, to } = options;
|
|
539
|
-
if (
|
|
540
|
-
|
|
928
|
+
if (
|
|
929
|
+
job !== JOB_NAMES.SYNC &&
|
|
930
|
+
job !== JOB_NAMES.CONVERGE &&
|
|
931
|
+
job !== JOB_NAMES.BACKGROUND_VERIFY
|
|
932
|
+
) {
|
|
933
|
+
throw new ConfigInvalidError(
|
|
934
|
+
"schedule job must be 'sync', 'converge' or 'background-verify'",
|
|
935
|
+
{ job },
|
|
936
|
+
);
|
|
541
937
|
}
|
|
542
938
|
const converge = job === JOB_NAMES.CONVERGE;
|
|
543
|
-
const
|
|
939
|
+
const verify = job === JOB_NAMES.BACKGROUND_VERIFY;
|
|
940
|
+
if (verify) this.#assertBackground("schedule({ job: 'background-verify' })");
|
|
941
|
+
const {
|
|
942
|
+
id = verify
|
|
943
|
+
? DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID
|
|
944
|
+
: converge
|
|
945
|
+
? DEFAULT_CONVERGE_SCHEDULER_ID
|
|
946
|
+
: DEFAULT_SCHEDULER_ID,
|
|
947
|
+
} = options;
|
|
544
948
|
assertName(id, 'id');
|
|
545
|
-
if (
|
|
949
|
+
if (job !== JOB_NAMES.SYNC && to !== undefined) {
|
|
546
950
|
throw new ConfigInvalidError('to only applies to a sync schedule', { to });
|
|
547
951
|
}
|
|
548
952
|
if ((every === undefined) === (pattern === undefined)) {
|
|
@@ -566,17 +970,24 @@ class MigrationQueue {
|
|
|
566
970
|
if (to !== undefined && !isBareFilename(to)) {
|
|
567
971
|
throw new ConfigInvalidError('to must be a migration filename', { to });
|
|
568
972
|
}
|
|
569
|
-
|
|
973
|
+
const queue = verify ? this.#backgroundQueue : this.#queue;
|
|
974
|
+
if (typeof queue.upsertJobScheduler !== 'function') {
|
|
570
975
|
throw new ConfigInvalidError(
|
|
571
976
|
'schedule() needs job schedulers (queue.upsertJobScheduler) — BullMQ 5.16 or newer',
|
|
572
977
|
);
|
|
573
978
|
}
|
|
574
|
-
|
|
979
|
+
let template;
|
|
980
|
+
if (verify) {
|
|
981
|
+
template = buildBackgroundVerifyJobTemplate({ jobOptions: this.#background.jobOptions });
|
|
982
|
+
} else if (converge) {
|
|
983
|
+
template = buildConvergeJobTemplate({ jobOptions: this.#jobOptions });
|
|
984
|
+
} else {
|
|
985
|
+
template = buildSyncJobTemplate({ to, jobOptions: this.#jobOptions });
|
|
986
|
+
}
|
|
987
|
+
await queue.upsertJobScheduler(
|
|
575
988
|
id,
|
|
576
989
|
{ ...(every !== undefined ? { every } : { pattern }), ...(tz !== undefined ? { tz } : {}) },
|
|
577
|
-
|
|
578
|
-
? buildConvergeJobTemplate({ jobOptions: this.#jobOptions })
|
|
579
|
-
: buildSyncJobTemplate({ to, jobOptions: this.#jobOptions }),
|
|
990
|
+
template,
|
|
580
991
|
);
|
|
581
992
|
}
|
|
582
993
|
|
|
@@ -593,7 +1004,11 @@ class MigrationQueue {
|
|
|
593
1004
|
'unschedule() needs job schedulers (queue.removeJobScheduler) — BullMQ 5.16 or newer',
|
|
594
1005
|
);
|
|
595
1006
|
}
|
|
596
|
-
|
|
1007
|
+
const removed = Boolean(await this.#queue.removeJobScheduler(id));
|
|
1008
|
+
// A schedule of the background queue (the drift watch) goes the same way.
|
|
1009
|
+
const background = this.#backgroundQueue;
|
|
1010
|
+
if (typeof background?.removeJobScheduler !== 'function') return removed;
|
|
1011
|
+
return Boolean(await background.removeJobScheduler(id)) || removed;
|
|
597
1012
|
}
|
|
598
1013
|
|
|
599
1014
|
/**
|
|
@@ -616,31 +1031,55 @@ class MigrationQueue {
|
|
|
616
1031
|
failures.push(error);
|
|
617
1032
|
}
|
|
618
1033
|
};
|
|
619
|
-
// The
|
|
620
|
-
// the queue must go to the next worker, not straight back
|
|
1034
|
+
// The workers stop fetching first, together: a job the shutdown below
|
|
1035
|
+
// puts back in the queue must go to the next worker, not straight back
|
|
1036
|
+
// to this one. Both processors are told to stop: a lane checkpoints at
|
|
1037
|
+
// its next batch and goes back to the queue. Nothing waits for a start
|
|
1038
|
+
// in progress before that — one can hang on Redis for good.
|
|
621
1039
|
const worker = this.#worker;
|
|
622
|
-
const
|
|
1040
|
+
const backgroundWorker = this.#backgroundWorker;
|
|
1041
|
+
const workersClosed = [
|
|
1042
|
+
worker ? attempt(() => worker.close(force)) : undefined,
|
|
1043
|
+
backgroundWorker ? attempt(() => backgroundWorker.close(force)) : undefined,
|
|
1044
|
+
];
|
|
623
1045
|
this.#processor.shutdown('Migration queue closing');
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
//
|
|
1046
|
+
this.#backgroundProcessor?.shutdown('Migration queue closing');
|
|
1047
|
+
// The watcher: its streams close, its last positions are saved, its
|
|
1048
|
+
// locks go to the next pod's watcher.
|
|
1049
|
+
const watcher = this.#backgroundWatcher;
|
|
1050
|
+
if (watcher) await attempt(() => watcher.stop());
|
|
1051
|
+
await Promise.all(workersClosed);
|
|
1052
|
+
// A worker still starting is closed too, not orphaned — the start sees
|
|
1053
|
+
// the close at its next step — and a start that failed is that call's
|
|
1054
|
+
// failure, not this one's.
|
|
627
1055
|
await this.#workerStarting?.catch(() => undefined);
|
|
628
1056
|
if (this.#worker && this.#worker !== worker) {
|
|
629
1057
|
await attempt(() => this.#worker.close(force));
|
|
630
1058
|
}
|
|
1059
|
+
await this.#backgroundStarting?.catch(() => undefined);
|
|
1060
|
+
if (this.#backgroundWorker && this.#backgroundWorker !== backgroundWorker) {
|
|
1061
|
+
await attempt(() => this.#backgroundWorker.close(force));
|
|
1062
|
+
}
|
|
1063
|
+
if (this.#backgroundWatcher && this.#backgroundWatcher !== watcher) {
|
|
1064
|
+
await attempt(() => this.#backgroundWatcher.stop());
|
|
1065
|
+
}
|
|
1066
|
+
const processors = [this.#processor];
|
|
1067
|
+
if (this.#backgroundProcessor) processors.push(this.#backgroundProcessor);
|
|
1068
|
+
if (!force) {
|
|
1069
|
+
for (const processor of processors) await attempt(() => processor.close());
|
|
1070
|
+
}
|
|
631
1071
|
if (this.#queueEvents && this.#ownsQueueEvents) await attempt(() => this.#queueEvents.close());
|
|
632
1072
|
if (this.#ownsQueue) await attempt(() => this.#queue.close());
|
|
1073
|
+
if (this.#ownsBackgroundQueue) await attempt(() => this.#backgroundQueue.close());
|
|
633
1074
|
if (force) {
|
|
634
|
-
// The
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
.close()
|
|
1075
|
+
// The work in flight is not waited for — but it keeps its connection
|
|
1076
|
+
// until it ends: a kit this object created is disconnected only once
|
|
1077
|
+
// the processors have settled.
|
|
1078
|
+
Promise.allSettled(processors.map((processor) => processor.close()))
|
|
639
1079
|
.then(() => (this.#ownsKit ? this.#kit.disconnect() : undefined))
|
|
640
1080
|
.catch(() => undefined);
|
|
641
|
-
} else {
|
|
642
|
-
await attempt(() => this.#
|
|
643
|
-
if (this.#ownsKit) await attempt(() => this.#kit.disconnect());
|
|
1081
|
+
} else if (this.#ownsKit) {
|
|
1082
|
+
await attempt(() => this.#kit.disconnect());
|
|
644
1083
|
}
|
|
645
1084
|
if (failures.length > 0) throw failures[0];
|
|
646
1085
|
}
|