@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +33 -2
  3. package/bullmq.d.ts +449 -6
  4. package/index.d.ts +1010 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +128 -14
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +480 -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 +366 -0
  21. package/src/core/background-engine.js +818 -0
  22. package/src/core/background-kit.js +425 -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 +605 -0
  33. package/src/core/background.js +1121 -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/migrator.js +904 -12
  42. package/src/core/options.js +16 -0
  43. package/src/core/run.js +26 -12
  44. package/src/core/runner.js +1 -1
  45. package/src/core/server-info.js +9 -2
  46. package/src/core/shard-info.js +76 -0
  47. package/src/core/versioning-spec.js +181 -0
  48. package/src/errors/index.js +88 -0
  49. package/src/index.js +16 -0
  50. package/src/utils/error.js +11 -2
  51. package/src/utils/loader.js +77 -9
  52. package/src/utils/migration-name.js +33 -1
  53. package/src/utils/telemetry.js +107 -0
  54. package/src/utils/template.js +62 -1
  55. package/src/versioning/config.js +155 -0
  56. package/src/versioning/document.js +326 -0
  57. package/src/versioning/index.js +50 -0
  58. package/src/versioning/internal.js +279 -0
  59. package/src/versioning/mongoose.js +151 -0
  60. package/src/versioning/occ.js +318 -0
  61. package/src/versioning/registry.js +187 -0
  62. package/src/versioning/upcaster.js +213 -0
  63. package/versioning.d.ts +666 -0
  64. 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,10 @@ class MigrationQueue {
108
221
  globalConcurrency = true,
109
222
  lockWait,
110
223
  allow,
224
+ background,
111
225
  } = options;
112
226
 
113
- if (!isPlainObject(bullmq)) {
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 = isPlainObject(Queue) && typeof Queue.addBulk === 'function';
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 = isPlainObject(QueueEvents) && typeof QueueEvents.on === 'function';
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('QueueEvents', QueueEvents, resolvedName, resolvedPrefix);
275
+ MigrationQueue.#assertSameQueue(
276
+ 'bullmq.QueueEvents',
277
+ QueueEvents,
278
+ resolvedName,
279
+ resolvedPrefix,
280
+ );
162
281
  }
163
282
  assertJobOptions(jobOptions);
164
- if (!isPlainObject(workerOptions)) {
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
- `bullmq.${label} is on queue "${instance.name}", not "${name}"`,
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
- `bullmq.${label} uses prefix "${instancePrefix}", not "${prefix}"`,
252
- { prefix },
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 (!isPlainObject(overrides)) {
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 (!isPlainObject(options)) {
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 (job !== JOB_NAMES.SYNC && job !== JOB_NAMES.CONVERGE) {
540
- throw new ConfigInvalidError("schedule job must be 'sync' or 'converge'", { job });
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 { id = converge ? DEFAULT_CONVERGE_SCHEDULER_ID : DEFAULT_SCHEDULER_ID } = options;
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 (converge && to !== undefined) {
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
- if (typeof this.#queue.upsertJobScheduler !== 'function') {
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
- await this.#queue.upsertJobScheduler(
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
- converge
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
- return Boolean(await this.#queue.removeJobScheduler(id));
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 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.
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 workerClosed = worker ? attempt(() => worker.close(force)) : undefined;
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
- 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.
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 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()
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.#processor.close());
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
  }