@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -3
  3. package/bullmq.d.ts +484 -8
  4. package/index.d.ts +1264 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +9 -2
  7. package/src/bullmq/background-processor.js +541 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +348 -21
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +484 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +375 -0
  21. package/src/core/background-engine.js +849 -0
  22. package/src/core/background-kit.js +432 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +610 -0
  33. package/src/core/background.js +1127 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migration-logger.js +279 -0
  42. package/src/core/migrator.js +1027 -22
  43. package/src/core/options.js +36 -0
  44. package/src/core/run-recorder.js +6 -1
  45. package/src/core/run.js +26 -12
  46. package/src/core/runner.js +34 -8
  47. package/src/core/server-info.js +9 -2
  48. package/src/core/shard-info.js +76 -0
  49. package/src/core/versioning-spec.js +181 -0
  50. package/src/errors/index.js +88 -0
  51. package/src/index.js +16 -0
  52. package/src/utils/error.js +11 -2
  53. package/src/utils/job-ref.js +44 -0
  54. package/src/utils/loader.js +77 -9
  55. package/src/utils/migration-name.js +33 -1
  56. package/src/utils/redact.js +140 -3
  57. package/src/utils/telemetry.js +110 -0
  58. package/src/utils/template.js +62 -1
  59. package/src/versioning/config.js +155 -0
  60. package/src/versioning/document.js +326 -0
  61. package/src/versioning/index.js +50 -0
  62. package/src/versioning/internal.js +279 -0
  63. package/src/versioning/mongoose.js +151 -0
  64. package/src/versioning/occ.js +318 -0
  65. package/src/versioning/registry.js +187 -0
  66. package/src/versioning/upcaster.js +213 -0
  67. package/versioning.d.ts +666 -0
  68. package/versioning.js +1 -0
@@ -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
- isPlainObject,
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 { assertJobOptions, enqueueConverge, enqueueDown, enqueueUp } = require('./producer.js');
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 (!isPlainObject(options)) {
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 (!isPlainObject(bullmq)) {
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 = isPlainObject(Queue) && typeof Queue.addBulk === 'function';
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 = isPlainObject(QueueEvents) && typeof QueueEvents.on === 'function';
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('QueueEvents', QueueEvents, resolvedName, resolvedPrefix);
276
+ MigrationQueue.#assertSameQueue(
277
+ 'bullmq.QueueEvents',
278
+ QueueEvents,
279
+ resolvedName,
280
+ resolvedPrefix,
281
+ );
162
282
  }
163
283
  assertJobOptions(jobOptions);
164
- if (!isPlainObject(workerOptions)) {
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
- `bullmq.${label} is on queue "${instance.name}", not "${name}"`,
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
- `bullmq.${label} uses prefix "${instancePrefix}", not "${prefix}"`,
252
- { prefix },
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 (!isPlainObject(overrides)) {
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 (!isPlainObject(options)) {
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 (job !== JOB_NAMES.SYNC && job !== JOB_NAMES.CONVERGE) {
540
- throw new ConfigInvalidError("schedule job must be 'sync' or 'converge'", { job });
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 { id = converge ? DEFAULT_CONVERGE_SCHEDULER_ID : DEFAULT_SCHEDULER_ID } = options;
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 (converge && to !== undefined) {
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
- if (typeof this.#queue.upsertJobScheduler !== 'function') {
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
- await this.#queue.upsertJobScheduler(
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
- converge
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
- return Boolean(await this.#queue.removeJobScheduler(id));
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 worker stops fetching first: a job the shutdown below puts back in
620
- // the queue must go to the next worker, not straight back to this one.
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 workerClosed = worker ? attempt(() => worker.close(force)) : undefined;
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
- await workerClosed;
625
- // A worker still starting is closed too, not orphaned — and a start that
626
- // failed is that call's failure, not this one's.
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 migration in flight is not waited for — but it keeps its
635
- // connection until it ends: a kit this object created is disconnected
636
- // only once the processor has settled.
637
- this.#processor
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.#processor.close());
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
  }