@alexify/migronaut 2.2.0 → 2.3.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 +107 -0
- package/README.md +33 -2
- package/bullmq.d.ts +449 -6
- package/index.d.ts +1010 -9
- package/migronaut.schema.json +93 -1
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +128 -14
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +480 -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 +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -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 +605 -0
- package/src/core/background.js +1121 -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/migrator.js +904 -12
- package/src/core/options.js +16 -0
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- 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/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +107 -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,10 @@ class MigrationQueue {
|
|
|
108
221
|
globalConcurrency = true,
|
|
109
222
|
lockWait,
|
|
110
223
|
allow,
|
|
224
|
+
background,
|
|
111
225
|
} = options;
|
|
112
226
|
|
|
113
|
-
if (!
|
|
227
|
+
if (!isObjectLike(bullmq)) {
|
|
114
228
|
throw new ConfigInvalidError(
|
|
115
229
|
'bullmq is required — pass { Queue, Worker, QueueEvents } from your own bullmq install',
|
|
116
230
|
);
|
|
@@ -125,14 +239,14 @@ class MigrationQueue {
|
|
|
125
239
|
{ telemetry: typeof telemetry },
|
|
126
240
|
);
|
|
127
241
|
}
|
|
128
|
-
const queueIsInstance =
|
|
242
|
+
const queueIsInstance = isObjectLike(Queue) && typeof Queue.addBulk === 'function';
|
|
129
243
|
if (!isClass(Queue) && !queueIsInstance) {
|
|
130
244
|
throw new ConfigInvalidError('bullmq.Queue must be the Queue class or a Queue instance');
|
|
131
245
|
}
|
|
132
246
|
if (Worker !== undefined && !isClass(Worker)) {
|
|
133
247
|
throw new ConfigInvalidError('bullmq.Worker must be the Worker class');
|
|
134
248
|
}
|
|
135
|
-
const eventsIsInstance =
|
|
249
|
+
const eventsIsInstance = isObjectLike(QueueEvents) && typeof QueueEvents.on === 'function';
|
|
136
250
|
if (QueueEvents !== undefined && !isClass(QueueEvents) && !eventsIsInstance) {
|
|
137
251
|
throw new ConfigInvalidError(
|
|
138
252
|
'bullmq.QueueEvents must be the QueueEvents class or a QueueEvents instance',
|
|
@@ -155,13 +269,18 @@ class MigrationQueue {
|
|
|
155
269
|
const resolvedPrefix = prefix ?? (queueIsInstance ? Queue.opts?.prefix : undefined);
|
|
156
270
|
if (resolvedPrefix !== undefined) assertName(resolvedPrefix, 'prefix');
|
|
157
271
|
if (queueIsInstance) {
|
|
158
|
-
MigrationQueue.#assertSameQueue('Queue', Queue, resolvedName, resolvedPrefix);
|
|
272
|
+
MigrationQueue.#assertSameQueue('bullmq.Queue', Queue, resolvedName, resolvedPrefix);
|
|
159
273
|
}
|
|
160
274
|
if (eventsIsInstance) {
|
|
161
|
-
MigrationQueue.#assertSameQueue(
|
|
275
|
+
MigrationQueue.#assertSameQueue(
|
|
276
|
+
'bullmq.QueueEvents',
|
|
277
|
+
QueueEvents,
|
|
278
|
+
resolvedName,
|
|
279
|
+
resolvedPrefix,
|
|
280
|
+
);
|
|
162
281
|
}
|
|
163
282
|
assertJobOptions(jobOptions);
|
|
164
|
-
if (!
|
|
283
|
+
if (!isObjectLike(workerOptions)) {
|
|
165
284
|
throw new ConfigInvalidError('workerOptions must be an object');
|
|
166
285
|
}
|
|
167
286
|
MigrationQueue.#assertConcurrency(workerOptions.concurrency);
|
|
@@ -190,6 +309,27 @@ class MigrationQueue {
|
|
|
190
309
|
...(allow !== undefined ? { allow } : {}),
|
|
191
310
|
});
|
|
192
311
|
this.#allow = resolveAllow(allow);
|
|
312
|
+
const backgroundSettings = resolveBackground(background, {
|
|
313
|
+
queueName: resolvedName,
|
|
314
|
+
QueueSource: Queue,
|
|
315
|
+
});
|
|
316
|
+
if (backgroundSettings?.queueIsInstance) {
|
|
317
|
+
// The same check as the migration queue's: a Worker built here on
|
|
318
|
+
// another name or prefix would listen to an empty queue.
|
|
319
|
+
MigrationQueue.#assertSameQueue(
|
|
320
|
+
'background.queue',
|
|
321
|
+
backgroundSettings.queue,
|
|
322
|
+
backgroundSettings.name,
|
|
323
|
+
resolvedPrefix,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (backgroundSettings !== undefined) {
|
|
327
|
+
// Validated against a stand-in queue: the real one does not exist yet.
|
|
328
|
+
resolveBackgroundProcessorOptions({
|
|
329
|
+
...MigrationQueue.#backgroundProcessorOptions(backgroundSettings),
|
|
330
|
+
queue: { addBulk() {} },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
193
333
|
|
|
194
334
|
this.#ownsKit = kit === undefined;
|
|
195
335
|
this.#kit = kit ?? new MigratorKit(config ?? {}, kitOptions);
|
|
@@ -207,6 +347,28 @@ class MigrationQueue {
|
|
|
207
347
|
error: errorText(error),
|
|
208
348
|
}),
|
|
209
349
|
);
|
|
350
|
+
if (backgroundSettings !== undefined) {
|
|
351
|
+
this.#background = backgroundSettings;
|
|
352
|
+
this.#ownsBackgroundQueue = !backgroundSettings.queueIsInstance;
|
|
353
|
+
this.#backgroundQueue = backgroundSettings.queueIsInstance
|
|
354
|
+
? backgroundSettings.queue
|
|
355
|
+
: new Queue(backgroundSettings.name, {
|
|
356
|
+
connection,
|
|
357
|
+
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
|
|
358
|
+
...(telemetry !== undefined ? { telemetry } : {}),
|
|
359
|
+
});
|
|
360
|
+
this.#listen(this.#backgroundQueue, 'error', (error) =>
|
|
361
|
+
this.#kit.logger.error(`✖ Background queue error: ${errorText(error)}`, {
|
|
362
|
+
queue: backgroundSettings.name,
|
|
363
|
+
error: errorText(error),
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
this.#backgroundProcessor = createBackgroundProcessor({
|
|
367
|
+
kit: this.#kit,
|
|
368
|
+
queue: this.#backgroundQueue,
|
|
369
|
+
...MigrationQueue.#backgroundProcessorOptions(backgroundSettings),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
210
372
|
this.#processor = createMigrationProcessor({
|
|
211
373
|
kit: this.#kit,
|
|
212
374
|
// `sync` jobs (and the converge jobs they add) enqueue into the queue
|
|
@@ -215,9 +377,63 @@ class MigrationQueue {
|
|
|
215
377
|
...(lockWait !== undefined ? { lockWait } : {}),
|
|
216
378
|
...(jobOptions !== undefined ? { jobOptions } : {}),
|
|
217
379
|
...(allow !== undefined ? { allow } : {}),
|
|
380
|
+
// What an `up` registers starts on the background queue at once.
|
|
381
|
+
...(this.#backgroundQueue !== undefined
|
|
382
|
+
? {
|
|
383
|
+
background: {
|
|
384
|
+
queue: this.#backgroundQueue,
|
|
385
|
+
...MigrationQueue.#backgroundEnqueueOptions(backgroundSettings),
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
: {}),
|
|
218
389
|
});
|
|
219
390
|
}
|
|
220
391
|
|
|
392
|
+
/** Whether the drift watch's schedule exists already — false when that cannot be told */
|
|
393
|
+
static async #hasScheduler(queue) {
|
|
394
|
+
if (typeof queue.getJobScheduler === 'function') {
|
|
395
|
+
return Boolean(await queue.getJobScheduler(DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID));
|
|
396
|
+
}
|
|
397
|
+
if (typeof queue.getJobSchedulers === 'function') {
|
|
398
|
+
for (const scheduler of await queue.getJobSchedulers()) {
|
|
399
|
+
if ((scheduler.id ?? scheduler.key) === DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID) return true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The background processor's options out of the resolved `background` option */
|
|
406
|
+
static #backgroundProcessorOptions(settings) {
|
|
407
|
+
const picked = {};
|
|
408
|
+
for (const key of [
|
|
409
|
+
'jobOptions',
|
|
410
|
+
'sliceMs',
|
|
411
|
+
'children',
|
|
412
|
+
'pollIntervalMs',
|
|
413
|
+
'stallMs',
|
|
414
|
+
'maxLaneRetries',
|
|
415
|
+
]) {
|
|
416
|
+
if (settings[key] !== undefined) picked[key] = settings[key];
|
|
417
|
+
}
|
|
418
|
+
return picked;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** What every coordinator enqueue from this object carries */
|
|
422
|
+
static #backgroundEnqueueOptions(settings) {
|
|
423
|
+
return {
|
|
424
|
+
...(settings.jobOptions !== undefined ? { jobOptions: settings.jobOptions } : {}),
|
|
425
|
+
stallMs: settings.stallMs ?? DEFAULT_STALL_MS,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#assertBackground(method) {
|
|
430
|
+
if (this.#background === undefined) {
|
|
431
|
+
throw new ConfigInvalidError(
|
|
432
|
+
`${method} needs the background queue — pass background: true to createMigrationQueue`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
221
437
|
/**
|
|
222
438
|
* Refuse, at the enqueue call, a request this object's own policy would
|
|
223
439
|
* refuse on the worker — a job that can only fail is better not added. The
|
|
@@ -238,19 +454,15 @@ class MigrationQueue {
|
|
|
238
454
|
/** An injected instance must be on the queue this object is configured for */
|
|
239
455
|
static #assertSameQueue(label, instance, name, prefix) {
|
|
240
456
|
if (typeof instance.name === 'string' && instance.name !== name) {
|
|
241
|
-
throw new ConfigInvalidError(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
queueName: name,
|
|
245
|
-
},
|
|
246
|
-
);
|
|
457
|
+
throw new ConfigInvalidError(`${label} is on queue "${instance.name}", not "${name}"`, {
|
|
458
|
+
queueName: name,
|
|
459
|
+
});
|
|
247
460
|
}
|
|
248
461
|
const instancePrefix = instance.opts?.prefix;
|
|
249
462
|
if (instancePrefix !== undefined && prefix !== undefined && instancePrefix !== prefix) {
|
|
250
|
-
throw new ConfigInvalidError(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
);
|
|
463
|
+
throw new ConfigInvalidError(`${label} uses prefix "${instancePrefix}", not "${prefix}"`, {
|
|
464
|
+
prefix,
|
|
465
|
+
});
|
|
254
466
|
}
|
|
255
467
|
}
|
|
256
468
|
|
|
@@ -301,6 +513,26 @@ class MigrationQueue {
|
|
|
301
513
|
return this.#processor;
|
|
302
514
|
}
|
|
303
515
|
|
|
516
|
+
/** The background queue (`background` option), if any */
|
|
517
|
+
get backgroundQueue() {
|
|
518
|
+
return this.#backgroundQueue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The background worker started by {@link startBackgroundWorker}, if any */
|
|
522
|
+
get backgroundWorker() {
|
|
523
|
+
return this.#backgroundWorker;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The background queue's processor, for a Worker you construct yourself */
|
|
527
|
+
get backgroundProcessor() {
|
|
528
|
+
return this.#backgroundProcessor;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The live drift watcher `startBackgroundWorker()` started, if any */
|
|
532
|
+
get backgroundWatcher() {
|
|
533
|
+
return this.#backgroundWatcher;
|
|
534
|
+
}
|
|
535
|
+
|
|
304
536
|
#ensureQueueEvents() {
|
|
305
537
|
if (this.#queueEvents) return this.#queueEvents;
|
|
306
538
|
// A connection opened after close() would have nobody to close it.
|
|
@@ -381,6 +613,48 @@ class MigrationQueue {
|
|
|
381
613
|
);
|
|
382
614
|
}
|
|
383
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Enqueue the coordinator of one background migration — or of every one with
|
|
618
|
+
* work to do. Idempotent: a coordinator already alive absorbs the add.
|
|
619
|
+
* @experimental
|
|
620
|
+
*/
|
|
621
|
+
async enqueueBackground(name, options = {}) {
|
|
622
|
+
this.#assertOpen();
|
|
623
|
+
this.#assertBackground('enqueueBackground()');
|
|
624
|
+
if (!isObjectLike(options)) {
|
|
625
|
+
throw new ConfigInvalidError('enqueueBackground options must be an object');
|
|
626
|
+
}
|
|
627
|
+
return enqueueBackground(this.#backgroundQueue, this.#kit, {
|
|
628
|
+
...MigrationQueue.#backgroundEnqueueOptions(this.#background),
|
|
629
|
+
...options,
|
|
630
|
+
...(name !== undefined ? { migration: name } : {}),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** A background migration's status (or every one's) — read from MongoDB */
|
|
635
|
+
async backgroundStatus(name) {
|
|
636
|
+
this.#assertOpen();
|
|
637
|
+
return this.#kit.backgroundStatus(name);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The drift watch, now — and, on a queue with a background side, a
|
|
642
|
+
* coordinator for whatever it reopened.
|
|
643
|
+
* @experimental
|
|
644
|
+
*/
|
|
645
|
+
async verifyBackground(options = {}) {
|
|
646
|
+
this.#assertOpen();
|
|
647
|
+
const result = await this.#kit.verifyBackground(options);
|
|
648
|
+
if (this.#background !== undefined && result.drift.length > 0) {
|
|
649
|
+
await enqueueBackground(
|
|
650
|
+
this.#backgroundQueue,
|
|
651
|
+
this.#kit,
|
|
652
|
+
MigrationQueue.#backgroundEnqueueOptions(this.#background),
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
384
658
|
/** Full migration status — read straight from MongoDB, not from the queue */
|
|
385
659
|
async status() {
|
|
386
660
|
this.#assertOpen();
|
|
@@ -414,7 +688,7 @@ class MigrationQueue {
|
|
|
414
688
|
*/
|
|
415
689
|
async startWorker(overrides = {}) {
|
|
416
690
|
this.#assertOpen();
|
|
417
|
-
if (!
|
|
691
|
+
if (!isObjectLike(overrides)) {
|
|
418
692
|
throw new ConfigInvalidError('startWorker options must be an object');
|
|
419
693
|
}
|
|
420
694
|
MigrationQueue.#assertConcurrency(overrides.concurrency);
|
|
@@ -481,6 +755,117 @@ class MigrationQueue {
|
|
|
481
755
|
return worker;
|
|
482
756
|
}
|
|
483
757
|
|
|
758
|
+
/**
|
|
759
|
+
* Start the background worker: coordinators and lanes of background
|
|
760
|
+
* migrations, side by side (concurrency 2 by default). Connects to MongoDB
|
|
761
|
+
* first, registers the drift watch's schedule (`verifyIntervalMs`), and
|
|
762
|
+
* heals — a coordinator for every background migration with work to do.
|
|
763
|
+
* Calling it again returns the same worker.
|
|
764
|
+
* @experimental
|
|
765
|
+
*/
|
|
766
|
+
async startBackgroundWorker(overrides = {}) {
|
|
767
|
+
this.#assertOpen();
|
|
768
|
+
this.#assertBackground('startBackgroundWorker()');
|
|
769
|
+
if (!isObjectLike(overrides)) {
|
|
770
|
+
throw new ConfigInvalidError('startBackgroundWorker options must be an object');
|
|
771
|
+
}
|
|
772
|
+
assertBackgroundConcurrency(overrides.concurrency);
|
|
773
|
+
if (!this.#WorkerClass) {
|
|
774
|
+
throw new ConfigInvalidError(
|
|
775
|
+
'startBackgroundWorker() needs the Worker class — pass bullmq: { Queue, Worker }',
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
this.#backgroundStarting ??= this.#startBackgroundWorker(overrides).catch((error) => {
|
|
779
|
+
this.#backgroundStarting = undefined;
|
|
780
|
+
throw error;
|
|
781
|
+
});
|
|
782
|
+
return this.#backgroundStarting;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async #startBackgroundWorker(overrides) {
|
|
786
|
+
await this.#kit.connect();
|
|
787
|
+
const queue = this.#backgroundQueue;
|
|
788
|
+
const { verifyIntervalMs, verifyIntervalGiven, workerOptions = {} } = this.#background;
|
|
789
|
+
if (
|
|
790
|
+
verifyIntervalMs !== false &&
|
|
791
|
+
typeof queue.upsertJobScheduler === 'function' &&
|
|
792
|
+
(verifyIntervalGiven || !(await MigrationQueue.#hasScheduler(queue)))
|
|
793
|
+
) {
|
|
794
|
+
await queue.upsertJobScheduler(
|
|
795
|
+
DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
|
|
796
|
+
{ every: verifyIntervalMs },
|
|
797
|
+
buildBackgroundVerifyJobTemplate({ jobOptions: this.#background.jobOptions }),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
this.#assertOpen();
|
|
801
|
+
const Worker = this.#WorkerClass;
|
|
802
|
+
// An injected background queue says where its keys live; its worker listens there.
|
|
803
|
+
const prefix = this.#background.queue?.opts?.prefix ?? this.#prefix;
|
|
804
|
+
const worker = new Worker(this.#background.name, this.#backgroundProcessor, {
|
|
805
|
+
connection: this.#connection,
|
|
806
|
+
...(prefix !== undefined ? { prefix } : {}),
|
|
807
|
+
lockDuration: DEFAULT_LOCK_DURATION_MS,
|
|
808
|
+
maxStalledCount: DEFAULT_MAX_STALLED_COUNT,
|
|
809
|
+
...(this.#telemetry !== undefined ? { telemetry: this.#telemetry } : {}),
|
|
810
|
+
concurrency: DEFAULT_BACKGROUND_CONCURRENCY,
|
|
811
|
+
...workerOptions,
|
|
812
|
+
...overrides,
|
|
813
|
+
});
|
|
814
|
+
this.#backgroundWorker = worker;
|
|
815
|
+
const fields = { queue: this.#background.name };
|
|
816
|
+
this.#listen(worker, 'error', (error) =>
|
|
817
|
+
this.#kit.logger.error(`✖ Background worker error: ${errorText(error)}`, {
|
|
818
|
+
...fields,
|
|
819
|
+
error: errorText(error),
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
this.#listen(worker, 'failed', (job, error) =>
|
|
823
|
+
this.#kit.logger.warn(
|
|
824
|
+
`✖ Background job failed${job?.id !== undefined ? ` (${job.id})` : ''}: ${errorText(error)}`,
|
|
825
|
+
{ ...fields, ...failedJobFields(job, error), error: errorText(error) },
|
|
826
|
+
),
|
|
827
|
+
);
|
|
828
|
+
await worker.waitUntilReady?.();
|
|
829
|
+
// Closing meanwhile: close() takes it from here — nothing more to start.
|
|
830
|
+
if (this.#closing) return worker;
|
|
831
|
+
await this.#backgroundProcessor.heal();
|
|
832
|
+
if (this.#closing) return worker;
|
|
833
|
+
await this.#startWatcher();
|
|
834
|
+
return worker;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The live drift watcher, in this process — when `background.watch` says
|
|
839
|
+
* so, or, unsaid, when `backgroundDrift` is `'stream'` or `'both'`. A
|
|
840
|
+
* watcher that cannot start (a standalone server) leaves the polling watch
|
|
841
|
+
* to it, with a warning: the worker itself is fine.
|
|
842
|
+
*/
|
|
843
|
+
async #startWatcher() {
|
|
844
|
+
let wanted = this.#background.watch;
|
|
845
|
+
wanted ??= (await this.#kit.driftMode()) !== 'poll';
|
|
846
|
+
if (wanted === false) return;
|
|
847
|
+
const logger = this.#kit.logger;
|
|
848
|
+
try {
|
|
849
|
+
this.#backgroundWatcher = await this.#kit.watchBackground({
|
|
850
|
+
...(typeof wanted === 'object' ? wanted : {}),
|
|
851
|
+
onError: (error, collection) =>
|
|
852
|
+
logger.warn(
|
|
853
|
+
`⚠ Drift watcher${collection ? ` (${collection})` : ''}: ${errorText(error)}`,
|
|
854
|
+
{
|
|
855
|
+
queue: this.#background.name,
|
|
856
|
+
...(collection ? { collection } : {}),
|
|
857
|
+
error: errorText(error),
|
|
858
|
+
},
|
|
859
|
+
),
|
|
860
|
+
});
|
|
861
|
+
} catch (error) {
|
|
862
|
+
logger.warn(`⚠ The live drift watcher did not start: ${errorText(error)}`, {
|
|
863
|
+
queue: this.#background.name,
|
|
864
|
+
error: errorText(error),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
484
869
|
/** Stop workers from picking up new jobs. The job in flight finishes */
|
|
485
870
|
async pause() {
|
|
486
871
|
this.#assertOpen();
|
|
@@ -532,17 +917,32 @@ class MigrationQueue {
|
|
|
532
917
|
*/
|
|
533
918
|
async schedule(options = {}) {
|
|
534
919
|
this.#assertOpen();
|
|
535
|
-
if (!
|
|
920
|
+
if (!isObjectLike(options)) {
|
|
536
921
|
throw new ConfigInvalidError('schedule options must be an object');
|
|
537
922
|
}
|
|
538
923
|
const { job = JOB_NAMES.SYNC, every, pattern, tz, to } = options;
|
|
539
|
-
if (
|
|
540
|
-
|
|
924
|
+
if (
|
|
925
|
+
job !== JOB_NAMES.SYNC &&
|
|
926
|
+
job !== JOB_NAMES.CONVERGE &&
|
|
927
|
+
job !== JOB_NAMES.BACKGROUND_VERIFY
|
|
928
|
+
) {
|
|
929
|
+
throw new ConfigInvalidError(
|
|
930
|
+
"schedule job must be 'sync', 'converge' or 'background-verify'",
|
|
931
|
+
{ job },
|
|
932
|
+
);
|
|
541
933
|
}
|
|
542
934
|
const converge = job === JOB_NAMES.CONVERGE;
|
|
543
|
-
const
|
|
935
|
+
const verify = job === JOB_NAMES.BACKGROUND_VERIFY;
|
|
936
|
+
if (verify) this.#assertBackground("schedule({ job: 'background-verify' })");
|
|
937
|
+
const {
|
|
938
|
+
id = verify
|
|
939
|
+
? DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID
|
|
940
|
+
: converge
|
|
941
|
+
? DEFAULT_CONVERGE_SCHEDULER_ID
|
|
942
|
+
: DEFAULT_SCHEDULER_ID,
|
|
943
|
+
} = options;
|
|
544
944
|
assertName(id, 'id');
|
|
545
|
-
if (
|
|
945
|
+
if (job !== JOB_NAMES.SYNC && to !== undefined) {
|
|
546
946
|
throw new ConfigInvalidError('to only applies to a sync schedule', { to });
|
|
547
947
|
}
|
|
548
948
|
if ((every === undefined) === (pattern === undefined)) {
|
|
@@ -566,17 +966,24 @@ class MigrationQueue {
|
|
|
566
966
|
if (to !== undefined && !isBareFilename(to)) {
|
|
567
967
|
throw new ConfigInvalidError('to must be a migration filename', { to });
|
|
568
968
|
}
|
|
569
|
-
|
|
969
|
+
const queue = verify ? this.#backgroundQueue : this.#queue;
|
|
970
|
+
if (typeof queue.upsertJobScheduler !== 'function') {
|
|
570
971
|
throw new ConfigInvalidError(
|
|
571
972
|
'schedule() needs job schedulers (queue.upsertJobScheduler) — BullMQ 5.16 or newer',
|
|
572
973
|
);
|
|
573
974
|
}
|
|
574
|
-
|
|
975
|
+
let template;
|
|
976
|
+
if (verify) {
|
|
977
|
+
template = buildBackgroundVerifyJobTemplate({ jobOptions: this.#background.jobOptions });
|
|
978
|
+
} else if (converge) {
|
|
979
|
+
template = buildConvergeJobTemplate({ jobOptions: this.#jobOptions });
|
|
980
|
+
} else {
|
|
981
|
+
template = buildSyncJobTemplate({ to, jobOptions: this.#jobOptions });
|
|
982
|
+
}
|
|
983
|
+
await queue.upsertJobScheduler(
|
|
575
984
|
id,
|
|
576
985
|
{ ...(every !== undefined ? { every } : { pattern }), ...(tz !== undefined ? { tz } : {}) },
|
|
577
|
-
|
|
578
|
-
? buildConvergeJobTemplate({ jobOptions: this.#jobOptions })
|
|
579
|
-
: buildSyncJobTemplate({ to, jobOptions: this.#jobOptions }),
|
|
986
|
+
template,
|
|
580
987
|
);
|
|
581
988
|
}
|
|
582
989
|
|
|
@@ -593,7 +1000,11 @@ class MigrationQueue {
|
|
|
593
1000
|
'unschedule() needs job schedulers (queue.removeJobScheduler) — BullMQ 5.16 or newer',
|
|
594
1001
|
);
|
|
595
1002
|
}
|
|
596
|
-
|
|
1003
|
+
const removed = Boolean(await this.#queue.removeJobScheduler(id));
|
|
1004
|
+
// A schedule of the background queue (the drift watch) goes the same way.
|
|
1005
|
+
const background = this.#backgroundQueue;
|
|
1006
|
+
if (typeof background?.removeJobScheduler !== 'function') return removed;
|
|
1007
|
+
return Boolean(await background.removeJobScheduler(id)) || removed;
|
|
597
1008
|
}
|
|
598
1009
|
|
|
599
1010
|
/**
|
|
@@ -616,31 +1027,55 @@ class MigrationQueue {
|
|
|
616
1027
|
failures.push(error);
|
|
617
1028
|
}
|
|
618
1029
|
};
|
|
619
|
-
// The
|
|
620
|
-
// the queue must go to the next worker, not straight back
|
|
1030
|
+
// The workers stop fetching first, together: a job the shutdown below
|
|
1031
|
+
// puts back in the queue must go to the next worker, not straight back
|
|
1032
|
+
// to this one. Both processors are told to stop: a lane checkpoints at
|
|
1033
|
+
// its next batch and goes back to the queue. Nothing waits for a start
|
|
1034
|
+
// in progress before that — one can hang on Redis for good.
|
|
621
1035
|
const worker = this.#worker;
|
|
622
|
-
const
|
|
1036
|
+
const backgroundWorker = this.#backgroundWorker;
|
|
1037
|
+
const workersClosed = [
|
|
1038
|
+
worker ? attempt(() => worker.close(force)) : undefined,
|
|
1039
|
+
backgroundWorker ? attempt(() => backgroundWorker.close(force)) : undefined,
|
|
1040
|
+
];
|
|
623
1041
|
this.#processor.shutdown('Migration queue closing');
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
//
|
|
1042
|
+
this.#backgroundProcessor?.shutdown('Migration queue closing');
|
|
1043
|
+
// The watcher: its streams close, its last positions are saved, its
|
|
1044
|
+
// locks go to the next pod's watcher.
|
|
1045
|
+
const watcher = this.#backgroundWatcher;
|
|
1046
|
+
if (watcher) await attempt(() => watcher.stop());
|
|
1047
|
+
await Promise.all(workersClosed);
|
|
1048
|
+
// A worker still starting is closed too, not orphaned — the start sees
|
|
1049
|
+
// the close at its next step — and a start that failed is that call's
|
|
1050
|
+
// failure, not this one's.
|
|
627
1051
|
await this.#workerStarting?.catch(() => undefined);
|
|
628
1052
|
if (this.#worker && this.#worker !== worker) {
|
|
629
1053
|
await attempt(() => this.#worker.close(force));
|
|
630
1054
|
}
|
|
1055
|
+
await this.#backgroundStarting?.catch(() => undefined);
|
|
1056
|
+
if (this.#backgroundWorker && this.#backgroundWorker !== backgroundWorker) {
|
|
1057
|
+
await attempt(() => this.#backgroundWorker.close(force));
|
|
1058
|
+
}
|
|
1059
|
+
if (this.#backgroundWatcher && this.#backgroundWatcher !== watcher) {
|
|
1060
|
+
await attempt(() => this.#backgroundWatcher.stop());
|
|
1061
|
+
}
|
|
1062
|
+
const processors = [this.#processor];
|
|
1063
|
+
if (this.#backgroundProcessor) processors.push(this.#backgroundProcessor);
|
|
1064
|
+
if (!force) {
|
|
1065
|
+
for (const processor of processors) await attempt(() => processor.close());
|
|
1066
|
+
}
|
|
631
1067
|
if (this.#queueEvents && this.#ownsQueueEvents) await attempt(() => this.#queueEvents.close());
|
|
632
1068
|
if (this.#ownsQueue) await attempt(() => this.#queue.close());
|
|
1069
|
+
if (this.#ownsBackgroundQueue) await attempt(() => this.#backgroundQueue.close());
|
|
633
1070
|
if (force) {
|
|
634
|
-
// The
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
.close()
|
|
1071
|
+
// The work in flight is not waited for — but it keeps its connection
|
|
1072
|
+
// until it ends: a kit this object created is disconnected only once
|
|
1073
|
+
// the processors have settled.
|
|
1074
|
+
Promise.allSettled(processors.map((processor) => processor.close()))
|
|
639
1075
|
.then(() => (this.#ownsKit ? this.#kit.disconnect() : undefined))
|
|
640
1076
|
.catch(() => undefined);
|
|
641
|
-
} else {
|
|
642
|
-
await attempt(() => this.#
|
|
643
|
-
if (this.#ownsKit) await attempt(() => this.#kit.disconnect());
|
|
1077
|
+
} else if (this.#ownsKit) {
|
|
1078
|
+
await attempt(() => this.#kit.disconnect());
|
|
644
1079
|
}
|
|
645
1080
|
if (failures.length > 0) throw failures[0];
|
|
646
1081
|
}
|