@alexify/migronaut 2.1.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 (71) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +68 -10
  3. package/bullmq.d.ts +465 -7
  4. package/index.d.ts +1272 -18
  5. package/migronaut.schema.json +150 -2
  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 +153 -15
  11. package/src/bullmq/producer.js +202 -27
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/converge.js +38 -10
  15. package/src/cli/commands/create.js +6 -0
  16. package/src/cli/exit-codes.js +6 -0
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/table.js +68 -9
  19. package/src/core/audit.js +98 -3
  20. package/src/core/background-audit.js +139 -0
  21. package/src/core/background-drift.js +126 -0
  22. package/src/core/background-dry-run.js +366 -0
  23. package/src/core/background-engine.js +818 -0
  24. package/src/core/background-kit.js +425 -0
  25. package/src/core/background-partition.js +298 -0
  26. package/src/core/background-runner.js +305 -0
  27. package/src/core/background-sandbox.js +701 -0
  28. package/src/core/background-shard.js +542 -0
  29. package/src/core/background-spec.js +597 -0
  30. package/src/core/background-store.js +951 -0
  31. package/src/core/background-throttle.js +269 -0
  32. package/src/core/background-watch-plan.js +164 -0
  33. package/src/core/background-watch-store.js +78 -0
  34. package/src/core/background-watch.js +605 -0
  35. package/src/core/background.js +1121 -0
  36. package/src/core/bson-peer.js +23 -0
  37. package/src/core/changelog.js +32 -0
  38. package/src/core/collections.js +125 -31
  39. package/src/core/config.js +133 -13
  40. package/src/core/converge-plan.js +343 -61
  41. package/src/core/converge-search-run.js +440 -0
  42. package/src/core/converge-search.js +404 -0
  43. package/src/core/converge.js +428 -183
  44. package/src/core/index-spec.js +27 -16
  45. package/src/core/lock.js +97 -32
  46. package/src/core/migrator.js +951 -26
  47. package/src/core/options.js +32 -1
  48. package/src/core/run.js +26 -12
  49. package/src/core/runner.js +1 -1
  50. package/src/core/search-index-spec.js +758 -0
  51. package/src/core/server-info.js +70 -0
  52. package/src/core/shard-info.js +76 -0
  53. package/src/core/versioning-spec.js +181 -0
  54. package/src/errors/index.js +97 -5
  55. package/src/index.js +16 -0
  56. package/src/utils/canonical.js +34 -1
  57. package/src/utils/error.js +11 -2
  58. package/src/utils/loader.js +77 -9
  59. package/src/utils/migration-name.js +33 -1
  60. package/src/utils/telemetry.js +125 -1
  61. package/src/utils/template.js +69 -1
  62. package/src/versioning/config.js +155 -0
  63. package/src/versioning/document.js +326 -0
  64. package/src/versioning/index.js +50 -0
  65. package/src/versioning/internal.js +279 -0
  66. package/src/versioning/mongoose.js +151 -0
  67. package/src/versioning/occ.js +318 -0
  68. package/src/versioning/registry.js +187 -0
  69. package/src/versioning/upcaster.js +213 -0
  70. package/versioning.d.ts +666 -0
  71. package/versioning.js +1 -0
@@ -1,15 +1,20 @@
1
+ const { createBackgroundProcessor } = require('./background-processor.js');
1
2
  const {
3
+ DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
2
4
  DEFAULT_CONVERGE_SCHEDULER_ID,
3
5
  DEFAULT_QUEUE_NAME,
4
6
  DEFAULT_SCHEDULER_ID,
5
7
  JOB_DATA_VERSION,
6
8
  JOB_NAMES,
7
9
  MIN_JOB_DATA_VERSION,
10
+ backgroundQueueName,
8
11
  dedupId,
12
+ parseBackgroundJobData,
9
13
  parseJobData,
10
14
  } = require('./jobs.js');
11
15
  const { RETRYABLE_CODES, createMigrationProcessor, isRetryableError } = require('./processor.js');
12
16
  const {
17
+ enqueueBackground,
13
18
  enqueueConverge,
14
19
  enqueueDown,
15
20
  enqueueUp,
@@ -41,6 +46,11 @@ module.exports = {
41
46
  planDownJobs,
42
47
  waitForGroup,
43
48
 
49
+ // Background migrations on a queue of their own (experimental)
50
+ createBackgroundProcessor,
51
+ enqueueBackground,
52
+ backgroundQueueName,
53
+
44
54
  // The job contract
45
55
  JOB_NAMES,
46
56
  JOB_DATA_VERSION,
@@ -48,8 +58,10 @@ module.exports = {
48
58
  DEFAULT_QUEUE_NAME,
49
59
  DEFAULT_SCHEDULER_ID,
50
60
  DEFAULT_CONVERGE_SCHEDULER_ID,
61
+ DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
51
62
  RETRYABLE_CODES,
52
63
  dedupId,
53
64
  isRetryableError,
65
+ parseBackgroundJobData,
54
66
  parseJobData,
55
67
  };
@@ -25,14 +25,28 @@ const MIN_JOB_DATA_VERSION = 1;
25
25
  /**
26
26
  * One job name per kind of work — `up`/`down` carry one migration each, `sync`
27
27
  * plans and enqueues what is pending, `converge` brings the declared
28
- * collections to their declared state.
28
+ * collections to their declared state. The background names live on a queue
29
+ * of their own (`<queueName>-background`): `background` is a background
30
+ * migration's coordinator, `background-lane` one of its lanes (a child of the
31
+ * coordinator), `background-verify` the drift watch.
29
32
  */
30
- const JOB_NAMES = Object.freeze({ UP: 'up', DOWN: 'down', SYNC: 'sync', CONVERGE: 'converge' });
33
+ const JOB_NAMES = Object.freeze({
34
+ UP: 'up',
35
+ DOWN: 'down',
36
+ SYNC: 'sync',
37
+ CONVERGE: 'converge',
38
+ BACKGROUND: 'background',
39
+ BACKGROUND_LANE: 'background-lane',
40
+ BACKGROUND_VERIFY: 'background-verify',
41
+ });
31
42
 
32
43
  const DEFAULT_QUEUE_NAME = 'migronaut';
33
44
  /** No `:` — BullMQ rejects it in custom ids */
34
45
  const DEFAULT_SCHEDULER_ID = 'migronaut-sync';
35
46
  const DEFAULT_CONVERGE_SCHEDULER_ID = 'migronaut-converge';
47
+ const DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID = 'migronaut-background-verify';
48
+ /** The background queue is named after the migration queue it serves */
49
+ const BACKGROUND_QUEUE_SUFFIX = '-background';
36
50
 
37
51
  /**
38
52
  * Forced onto every migration job, over anything the caller configured. A
@@ -55,7 +69,21 @@ const FORBIDDEN_JOB_OPTIONS = Object.freeze([
55
69
  'parent',
56
70
  ]);
57
71
 
72
+ /**
73
+ * Refused in a background queue's `jobOptions` too: what a coordinator or a
74
+ * lane does when its children fail is the adapter's design, not a knob.
75
+ */
76
+ const BACKGROUND_FORBIDDEN_JOB_OPTIONS = Object.freeze([
77
+ ...FORBIDDEN_JOB_OPTIONS,
78
+ 'failParentOnFailure',
79
+ 'continueParentOnFailure',
80
+ 'ignoreDependencyOnFailure',
81
+ 'removeDependencyOnFailure',
82
+ ]);
83
+
58
84
  const MAX_MIGRATION_NAME_LENGTH = 255;
85
+ /** At most this many lanes — a background migration's `maxParallel` cap */
86
+ const MAX_LANES = 64;
59
87
  /** A file checksum as migronaut computes it: a SHA-256 hex digest */
60
88
  const CHECKSUM_PATTERN = /^[0-9a-f]{64}$/;
61
89
 
@@ -77,13 +105,40 @@ const JOB_FIELDS = Object.freeze({
77
105
  ]),
78
106
  sync: new Set(['v', 'kind', 'to']),
79
107
  converge: new Set(['v', 'kind', 'groupId', 'ordered', 'requestedBy', 'reason']),
108
+ background: new Set([
109
+ 'v',
110
+ 'kind',
111
+ 'migration',
112
+ 'round',
113
+ 'spawn',
114
+ 'takeover',
115
+ 'requestedBy',
116
+ 'reason',
117
+ ]),
118
+ 'background-lane': new Set([
119
+ 'v',
120
+ 'kind',
121
+ 'migration',
122
+ 'registration',
123
+ 'generation',
124
+ 'round',
125
+ 'spawn',
126
+ 'lane',
127
+ 'retry',
128
+ ]),
129
+ 'background-verify': new Set(['v', 'kind']),
80
130
  });
81
131
  /** The limit every migronaut id is minted under — a producer's own check and this one agree */
82
132
  const MAX_GROUP_ID_LENGTH = MAX_ID_LENGTH;
83
133
 
84
- const isPlainObject = (value) =>
134
+ /**
135
+ * An object that is not an array — class instances included: a Queue the
136
+ * caller hands over is one (not a "plain object" in versioning/'s sense).
137
+ */
138
+ const isObjectLike = (value) =>
85
139
  value !== null && typeof value === 'object' && !Array.isArray(value);
86
140
  const isPositiveInteger = (value) => Number.isSafeInteger(value) && value > 0;
141
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
87
142
 
88
143
  /**
89
144
  * A migration name as an id fragment: letters, digits, `.`, `_` and `-` as
@@ -141,7 +196,7 @@ const DEFAULT_ALLOW = Object.freeze({ down: true, force: false, unordered: false
141
196
  /** Validate an `allow` option and fill in the defaults */
142
197
  function resolveAllow(allow) {
143
198
  if (allow === undefined) return DEFAULT_ALLOW;
144
- if (!isPlainObject(allow)) {
199
+ if (!isObjectLike(allow)) {
145
200
  throw new ConfigInvalidError('allow must be an object', { allow: typeof allow });
146
201
  }
147
202
  for (const key of Object.keys(allow)) {
@@ -199,7 +254,7 @@ function invalid(job, issue) {
199
254
  * above all the migration name, which becomes a filesystem path.
200
255
  */
201
256
  function parseJobData(job) {
202
- if (!isPlainObject(job)) throw invalid(job, 'job is not an object');
257
+ if (!isObjectLike(job)) throw invalid(job, 'job is not an object');
203
258
  const { name, data } = job;
204
259
  if (
205
260
  name !== JOB_NAMES.UP &&
@@ -209,7 +264,7 @@ function parseJobData(job) {
209
264
  ) {
210
265
  throw invalid(job, 'unknown job name');
211
266
  }
212
- if (!isPlainObject(data)) throw invalid(job, 'data is not an object');
267
+ if (!isObjectLike(data)) throw invalid(job, 'data is not an object');
213
268
  if (!Number.isSafeInteger(data.v) || data.v < MIN_JOB_DATA_VERSION) {
214
269
  throw invalid(job, 'unsupported job data version');
215
270
  }
@@ -309,6 +364,87 @@ function parseJobData(job) {
309
364
  };
310
365
  }
311
366
 
367
+ /** The version and field checks every job shares — then the fields of `kind` */
368
+ function assertEnvelope(job, kind) {
369
+ const { data } = job;
370
+ if (!isObjectLike(data)) throw invalid(job, 'data is not an object');
371
+ if (!Number.isSafeInteger(data.v) || data.v < MIN_JOB_DATA_VERSION) {
372
+ throw invalid(job, 'unsupported job data version');
373
+ }
374
+ if (data.v > JOB_DATA_VERSION) {
375
+ throw invalid(
376
+ job,
377
+ `job data version ${data.v} is newer than this worker supports (${JOB_DATA_VERSION}) — ` +
378
+ 'roll the workers out before the producers',
379
+ );
380
+ }
381
+ if (data.kind !== kind) throw invalid(job, 'kind does not match the job name');
382
+ for (const key of Object.keys(data)) {
383
+ if (!JOB_FIELDS[kind].has(key)) throw invalid(job, `unknown field "${key}"`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Validate a job read back from a background queue and return a normalized
389
+ * copy — as untrusted as any other: the migration name becomes a path.
390
+ */
391
+ function parseBackgroundJobData(job) {
392
+ if (!isObjectLike(job)) throw invalid(job, 'job is not an object');
393
+ const { name } = job;
394
+ if (
395
+ name !== JOB_NAMES.BACKGROUND &&
396
+ name !== JOB_NAMES.BACKGROUND_LANE &&
397
+ name !== JOB_NAMES.BACKGROUND_VERIFY
398
+ ) {
399
+ throw invalid(job, 'unknown background job name');
400
+ }
401
+ assertEnvelope(job, name);
402
+ const { data } = job;
403
+ if (name === JOB_NAMES.BACKGROUND_VERIFY) return { kind: name };
404
+ if (!isBareFilename(data.migration) || data.migration.length > MAX_MIGRATION_NAME_LENGTH) {
405
+ throw invalid(job, 'migration is not a bare filename');
406
+ }
407
+ for (const key of ['round', 'spawn', 'generation', 'retry']) {
408
+ if (data[key] !== undefined && !isCount(data[key])) {
409
+ throw invalid(job, `${key} is not a non-negative integer`);
410
+ }
411
+ }
412
+ if (name === JOB_NAMES.BACKGROUND) {
413
+ for (const key of ['requestedBy', 'reason']) {
414
+ const issue = actorIssue(key, data[key]);
415
+ if (issue) throw invalid(job, issue);
416
+ }
417
+ if (data.takeover !== undefined && data.takeover !== true) {
418
+ throw invalid(job, 'takeover is only valid as `true`');
419
+ }
420
+ return {
421
+ kind: name,
422
+ migration: data.migration,
423
+ ...(data.round !== undefined ? { round: data.round } : {}),
424
+ ...(data.spawn !== undefined ? { spawn: data.spawn } : {}),
425
+ ...(data.takeover ? { takeover: true } : {}),
426
+ ...pickActor(data),
427
+ };
428
+ }
429
+ if (!isGroupId(data.registration)) throw invalid(job, 'registration is not a short string');
430
+ for (const key of ['generation', 'round', 'spawn']) {
431
+ if (data[key] === undefined) throw invalid(job, `${key} is missing`);
432
+ }
433
+ if (!Number.isSafeInteger(data.lane) || data.lane < 0 || data.lane >= MAX_LANES) {
434
+ throw invalid(job, `lane is not an integer from 0 to ${MAX_LANES - 1}`);
435
+ }
436
+ return {
437
+ kind: name,
438
+ migration: data.migration,
439
+ registration: data.registration,
440
+ generation: data.generation,
441
+ round: data.round,
442
+ spawn: data.spawn,
443
+ lane: data.lane,
444
+ retry: data.retry ?? 0,
445
+ };
446
+ }
447
+
312
448
  /**
313
449
  * Build one `up`/`down` job spec, ready for `queue.addBulk`. `ordered` is
314
450
  * always written: a job must say how it is to run, not leave it to whatever
@@ -427,8 +563,112 @@ function buildConvergeJobTemplate({ jobOptions } = {}) {
427
563
  };
428
564
  }
429
565
 
566
+ /** The background queue that serves a migration queue */
567
+ function backgroundQueueName(queueName) {
568
+ return `${queueName}${BACKGROUND_QUEUE_SUFFIX}`;
569
+ }
570
+
571
+ /**
572
+ * Options of a background queue's own jobs: the caller's passthrough, then a
573
+ * bounded retention where they set none — a lane per partition per spawn adds
574
+ * up — then what the contract owns.
575
+ */
576
+ function backgroundJobOptions(jobOptions = {}, owned = {}) {
577
+ return {
578
+ removeOnComplete: TICK_RETENTION.removeOnComplete,
579
+ removeOnFail: TICK_RETENTION.removeOnFail,
580
+ ...jobOptions,
581
+ ...owned,
582
+ };
583
+ }
584
+
585
+ /**
586
+ * The coordinator job of a background migration. Deduplicated on its name, so
587
+ * every pod's heal and every sync tick collapse into the one coordinator
588
+ * chain that is alive (waiting, delayed or waiting for its lanes); a takeover
589
+ * of a stalled one gets an id of its own per round. A few attempts with a long
590
+ * backoff are the outer safety net — what it decides is all in MongoDB.
591
+ */
592
+ function buildBackgroundJob({ migration, takeoverOf, jobOptions, requestedBy, reason }) {
593
+ const fragment = idFragment(migration);
594
+ return {
595
+ name: JOB_NAMES.BACKGROUND,
596
+ data: {
597
+ v: JOB_DATA_VERSION,
598
+ kind: JOB_NAMES.BACKGROUND,
599
+ migration,
600
+ ...(takeoverOf !== undefined ? { takeover: true } : {}),
601
+ ...pickActor({ requestedBy, reason }),
602
+ },
603
+ opts: backgroundJobOptions(jobOptions, {
604
+ attempts: 3,
605
+ backoff: { type: 'fixed', delay: 30_000 },
606
+ deduplication: {
607
+ id: takeoverOf === undefined ? `bg-${fragment}` : `bg-${fragment}-t${takeoverOf}`,
608
+ },
609
+ }),
610
+ };
611
+ }
612
+
613
+ /**
614
+ * One lane of a background migration. Under a coordinator (`parent`) its id
615
+ * must be new for every spawn — BullMQ will not move an existing job to
616
+ * another parent, and a job it already finished would never wake this one;
617
+ * with no parent (`children: false`) the id is deduplicated per lane slot.
618
+ */
619
+ function buildLaneJob({
620
+ migration,
621
+ registration,
622
+ generation,
623
+ round,
624
+ spawn,
625
+ lane,
626
+ parent,
627
+ jobOptions,
628
+ }) {
629
+ const base = `bgl-${idFragment(migration)}-${idFragment(registration)}-g${generation}`;
630
+ return {
631
+ name: JOB_NAMES.BACKGROUND_LANE,
632
+ data: {
633
+ v: JOB_DATA_VERSION,
634
+ kind: JOB_NAMES.BACKGROUND_LANE,
635
+ migration,
636
+ registration,
637
+ generation,
638
+ round,
639
+ spawn,
640
+ lane,
641
+ },
642
+ opts: backgroundJobOptions(
643
+ jobOptions,
644
+ parent === undefined
645
+ ? { attempts: 1, deduplication: { id: `${base}-l${lane}` } }
646
+ : {
647
+ attempts: 1,
648
+ jobId: `${base}-r${round}-s${spawn}-l${lane}`,
649
+ parent,
650
+ // A lane that fails still wakes its coordinator — which decides
651
+ // from MongoDB, never from how its lanes ended.
652
+ ignoreDependencyOnFailure: true,
653
+ },
654
+ ),
655
+ };
656
+ }
657
+
658
+ /** The job a drift-watch schedule produces — a tick like the `sync` one */
659
+ function buildBackgroundVerifyJobTemplate({ jobOptions } = {}) {
660
+ return {
661
+ name: JOB_NAMES.BACKGROUND_VERIFY,
662
+ data: { v: JOB_DATA_VERSION, kind: JOB_NAMES.BACKGROUND_VERIFY },
663
+ opts: tickJobOptions(jobOptions),
664
+ };
665
+ }
666
+
430
667
  module.exports = {
668
+ BACKGROUND_FORBIDDEN_JOB_OPTIONS,
669
+ BACKGROUND_QUEUE_SUFFIX,
431
670
  DEFAULT_ALLOW,
671
+ DEFAULT_BACKGROUND_VERIFY_SCHEDULER_ID,
432
672
  DEFAULT_CONVERGE_SCHEDULER_ID,
433
673
  DEFAULT_QUEUE_NAME,
434
674
  DEFAULT_SCHEDULER_ID,
@@ -439,15 +679,22 @@ module.exports = {
439
679
  MIN_JOB_DATA_VERSION,
440
680
  MIGRATION_JOB_OPTIONS,
441
681
  TICK_RETENTION,
682
+ MAX_LANES,
442
683
  assertAllowed,
684
+ backgroundQueueName,
685
+ buildBackgroundJob,
686
+ buildBackgroundVerifyJobTemplate,
443
687
  buildConvergeJob,
444
688
  buildConvergeJobTemplate,
445
689
  buildMigrationJob,
690
+ buildLaneJob,
446
691
  buildSyncJobTemplate,
447
692
  convergeDedupId,
448
693
  dedupId,
449
- isPlainObject,
694
+ idFragment,
695
+ isObjectLike,
450
696
  migrationJobOptions,
697
+ parseBackgroundJobData,
451
698
  parseJobData,
452
699
  permissionsNeeded,
453
700
  resolveAllow,
@@ -11,14 +11,14 @@ const {
11
11
  const { pickActor } = require('../utils/actor.js');
12
12
  const { errorText } = require('../utils/error.js');
13
13
  const { redactDeep, redactOutbound } = require('../utils/redact.js');
14
+ const { JOB_NAMES, assertAllowed, isObjectLike, parseJobData, resolveAllow } = require('./jobs.js');
14
15
  const {
15
- JOB_NAMES,
16
- assertAllowed,
17
- isPlainObject,
18
- parseJobData,
19
- resolveAllow,
20
- } = require('./jobs.js');
21
- const { assertJobOptions, enqueueConverge, enqueueUp } = require('./producer.js');
16
+ assertBackgroundJobOptions,
17
+ assertJobOptions,
18
+ enqueueBackground,
19
+ enqueueConverge,
20
+ enqueueUp,
21
+ } = require('./producer.js');
22
22
 
23
23
  /**
24
24
  * Failures a later attempt can get past without anything being fixed: the lock
@@ -136,10 +136,10 @@ function prepareErrorForQueue(error) {
136
136
  * opens any connection of its own.
137
137
  */
138
138
  function resolveProcessorOptions(options) {
139
- if (!isPlainObject(options)) {
139
+ if (!isObjectLike(options)) {
140
140
  throw new ConfigInvalidError('createMigrationProcessor options must be an object');
141
141
  }
142
- const { kit, config, lockWait = {}, jobOptions, ordered = true, allow } = options;
142
+ const { kit, config, lockWait = {}, jobOptions, ordered = true, allow, background } = options;
143
143
  if (kit !== undefined && config !== undefined) {
144
144
  throw new ConfigInvalidError('Pass either `kit` or `config`, not both');
145
145
  }
@@ -149,7 +149,7 @@ function resolveProcessorOptions(options) {
149
149
  if (typeof ordered !== 'boolean') {
150
150
  throw new ConfigInvalidError('ordered must be a boolean', { ordered });
151
151
  }
152
- if (!isPlainObject(lockWait)) {
152
+ if (!isObjectLike(lockWait)) {
153
153
  throw new ConfigInvalidError('lockWait must be an object', { lockWait: typeof lockWait });
154
154
  }
155
155
  // Unlike runMigrations, waiting is the default: nothing is blocked on this
@@ -157,9 +157,26 @@ function resolveProcessorOptions(options) {
157
157
  const waitOptions = { onLockHeld: 'wait', ...lockWait };
158
158
  assertLockWaitOptions(waitOptions);
159
159
  assertJobOptions(jobOptions);
160
+ if (background !== undefined) assertBackgroundLink(background);
160
161
  return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow) };
161
162
  }
162
163
 
164
+ /**
165
+ * The background queue a migration processor hands what it registers to:
166
+ * `{ queue, jobOptions?, stallMs? }`.
167
+ */
168
+ function assertBackgroundLink(background) {
169
+ if (!isObjectLike(background) || typeof background.queue?.addBulk !== 'function') {
170
+ throw new ConfigInvalidError('background must be { queue } — the background queue');
171
+ }
172
+ for (const key of Object.keys(background)) {
173
+ if (key !== 'queue' && key !== 'jobOptions' && key !== 'stallMs') {
174
+ throw new ConfigInvalidError(`background.${key} is not an option here`, { key });
175
+ }
176
+ }
177
+ assertBackgroundJobOptions(background.jobOptions);
178
+ }
179
+
163
180
  /**
164
181
  * Build the function a BullMQ Worker runs for each migration job.
165
182
  *
@@ -173,7 +190,7 @@ function resolveProcessorOptions(options) {
173
190
  */
174
191
  function createMigrationProcessor(options = {}) {
175
192
  const { waitOptions, defaultOrdered, allow } = resolveProcessorOptions(options);
176
- const { kit: injectedKit, config, kitOptions, queue, jobOptions } = options;
193
+ const { kit: injectedKit, config, kitOptions, queue, jobOptions, background } = options;
177
194
 
178
195
  const ownsKit = injectedKit === undefined;
179
196
  const kit = injectedKit ?? new MigratorKit(config ?? {}, kitOptions);
@@ -246,6 +263,9 @@ function createMigrationProcessor(options = {}) {
246
263
  'migration:skipped': (event) => {
247
264
  if (current) log(current, `⏭ Skipped ${event.migration} (${event.reason ?? 'skipped'})`);
248
265
  },
266
+ 'background:registered': (event) => {
267
+ if (current) current.registered.push(event.migration);
268
+ },
249
269
  'converge:start': () => {
250
270
  if (!current) return;
251
271
  current.started = true;
@@ -253,18 +273,88 @@ function createMigrationProcessor(options = {}) {
253
273
  },
254
274
  'converge:action': (event) => {
255
275
  if (!current) return;
256
- const target = event.target === 'index' ? `index ${event.name}` : event.target;
276
+ const target =
277
+ event.target === 'index'
278
+ ? `index ${event.name}`
279
+ : event.target === 'searchIndex'
280
+ ? `search index ${event.name}`
281
+ : event.target;
257
282
  const what = `${event.action} ${target} on ${event.collection}`;
258
283
  if (event.status === 'started') log(current, `… ${what}`);
259
284
  else if (event.status === 'applied') log(current, `✔ ${what} [${event.durationMs ?? 0}ms]`);
260
285
  else log(current, `✖ ${what}: ${event.error ?? 'failed'}`);
261
286
  },
287
+ 'converge:wait': (event) => {
288
+ if (!current) return;
289
+ if (event.status === 'started' || event.status === 'progress') {
290
+ const waited = event.status === 'started' ? 0 : event.waitedMs;
291
+ log(
292
+ current,
293
+ event.status === 'started'
294
+ ? `… Waiting for ${event.searchIndexes} search index(es) to become queryable` +
295
+ (event.lockReleased ? ' — the migration lock is released meanwhile' : '')
296
+ : `… Still waiting for search indexes [${Math.round(waited / 1000)}s]`,
297
+ );
298
+ progress(current, 'search-wait', { searchIndexes: event.searchIndexes, waitedMs: waited });
299
+ } else if (event.status === 'ready') {
300
+ log(current, `✔ Search index(es) queryable [${event.waitedMs}ms]`);
301
+ } else {
302
+ log(current, `✖ Wait for search indexes ended: ${event.status} [${event.waitedMs}ms]`);
303
+ }
304
+ },
262
305
  'converge:end': (event) => {
263
306
  if (current && event.success) log(current, `✔ Converged ${event.changed} change(s)`);
264
307
  },
265
308
  };
266
309
  for (const [event, listener] of Object.entries(listeners)) kit.on(event, listener);
267
310
 
311
+ /** The background queue options every enqueue from here shares */
312
+ const backgroundOptions = () => ({
313
+ ...(background.jobOptions !== undefined ? { jobOptions: background.jobOptions } : {}),
314
+ ...(background.stallMs !== undefined ? { stallMs: background.stallMs } : {}),
315
+ });
316
+
317
+ /**
318
+ * Hand what this job registered to the background queue. Never fails the
319
+ * job — it is applied; a coordinator that could not be added now is added
320
+ * by the next heal (a sync tick, a verify tick, a worker's start).
321
+ */
322
+ async function startBackground(ctx) {
323
+ if (background === undefined || ctx.registered.length === 0) return undefined;
324
+ const started = [];
325
+ for (const migration of ctx.registered) {
326
+ try {
327
+ const { jobs } = await enqueueBackground(background.queue, kit, {
328
+ migration,
329
+ ...backgroundOptions(),
330
+ });
331
+ for (const job of jobs) started.push({ migration, jobId: job.id });
332
+ } catch (error) {
333
+ kit.logger.warn(
334
+ `⚠ Could not enqueue background migration ${migration}: ${errorText(error)} — ` +
335
+ 'the next heal will',
336
+ { ...jobIds(ctx), migration, error: errorText(error) },
337
+ );
338
+ }
339
+ }
340
+ for (const entry of started) log(ctx, `⧗ Background coordinator ${entry.jobId} enqueued`);
341
+ return started;
342
+ }
343
+
344
+ /** Every background migration with work to do gets its coordinator — a sync tick's heal */
345
+ async function healBackground() {
346
+ if (background === undefined) return undefined;
347
+ try {
348
+ const { jobs } = await enqueueBackground(background.queue, kit, backgroundOptions());
349
+ return jobs.length;
350
+ } catch (error) {
351
+ kit.logger.warn(`⚠ Background heal failed: ${errorText(error)}`, {
352
+ error: errorText(error),
353
+ });
354
+ return 0;
355
+ }
356
+ }
357
+
268
358
  function resultOf(ctx, rows, waitedMs) {
269
359
  const { data } = ctx;
270
360
  const row = rows[0];
@@ -296,7 +386,8 @@ function createMigrationProcessor(options = {}) {
296
386
 
297
387
  try {
298
388
  const { result, waitedMs } = await waitForLock(ctx, attempt, signal);
299
- return resultOf(ctx, result, waitedMs);
389
+ const started = await startBackground(ctx);
390
+ return { ...resultOf(ctx, result, waitedMs), ...(started ? { background: started } : {}) };
300
391
  } catch (error) {
301
392
  // A duplicate rollback job: the first one already reverted it. Same
302
393
  // outcome as a duplicate `up` job, which the kit reports as skipped.
@@ -362,6 +453,7 @@ function createMigrationProcessor(options = {}) {
362
453
  inSync: result.inSync,
363
454
  collections: result.collections,
364
455
  ...(result.unstable ? { unstable: result.unstable } : {}),
456
+ ...(result.search ? { search: result.search } : {}),
365
457
  ...(ctx.runId ? { runId: ctx.runId } : {}),
366
458
  lockWaitMs: waitedMs,
367
459
  });
@@ -391,6 +483,9 @@ function createMigrationProcessor(options = {}) {
391
483
  };
392
484
  }
393
485
 
486
+ /** What the last sync tick found the line waiting for — `{ migration, waitsFor }` */
487
+ let lastWaiting;
488
+
394
489
  async function runSyncJob(ctx) {
395
490
  if (!queue) {
396
491
  throw new ConfigInvalidError(
@@ -398,6 +493,9 @@ function createMigrationProcessor(options = {}) {
398
493
  );
399
494
  }
400
495
  const { to } = ctx.data;
496
+ // Background migrations first: whatever this tick enqueues may wait for one.
497
+ const healed = await healBackground();
498
+ const backgroundField = healed !== undefined ? { background: { enqueued: healed } } : {};
401
499
  // The cheap probe first: a scheduler ticks far more often than there is
402
500
  // anything to do, and planning proper re-reads the whole directory.
403
501
  const pending = await kit.list('pending');
@@ -409,6 +507,7 @@ function createMigrationProcessor(options = {}) {
409
507
  enqueued: 0,
410
508
  upToDate: true,
411
509
  migrations: [],
510
+ ...backgroundField,
412
511
  };
413
512
  // With `convergeAfterUp`, a tick that finds no migration still checks
414
513
  // the declared collections — a deploy that only changed a definition
@@ -440,17 +539,34 @@ function createMigrationProcessor(options = {}) {
440
539
  upToDate: false,
441
540
  migrations: [],
442
541
  held,
542
+ ...backgroundField,
543
+ };
544
+ }
545
+ // Still waiting where the last tick found it waiting, for what is still
546
+ // not done: said again without planning — a background migration takes
547
+ // hours, and planning re-reads the whole directory on every tick.
548
+ if (lastWaiting?.migration === pending[0].file && (await stillWaiting(lastWaiting))) {
549
+ return {
550
+ kind: 'sync',
551
+ groupId: null,
552
+ batch: null,
553
+ enqueued: 0,
554
+ upToDate: false,
555
+ migrations: [],
556
+ waiting: lastWaiting,
557
+ ...backgroundField,
443
558
  };
444
559
  }
445
560
  const group = await enqueueUp(queue, kit, {
446
561
  ...(to !== undefined ? { to } : {}),
447
562
  ...(jobOptions ? { jobOptions } : {}),
448
563
  });
564
+ lastWaiting = group.waiting;
449
565
  const migrations = [];
450
566
  for (const job of group.jobs) migrations.push(job.migration);
451
567
  return {
452
568
  kind: 'sync',
453
- groupId: group.upToDate ? null : group.groupId,
569
+ groupId: group.jobs.length === 0 ? null : group.groupId,
454
570
  batch: group.batch,
455
571
  enqueued: group.jobs.length,
456
572
  upToDate: group.upToDate,
@@ -458,9 +574,22 @@ function createMigrationProcessor(options = {}) {
458
574
  ...(group.converge
459
575
  ? { converge: { jobId: group.converge.id, deduplicated: group.converge.deduplicated } }
460
576
  : {}),
577
+ // Waiting is not held: `held` stays the circuit breaker on a failure.
578
+ ...(group.waiting ? { waiting: group.waiting } : {}),
579
+ ...backgroundField,
461
580
  };
462
581
  }
463
582
 
583
+ /** Whether a background migration `waiting` waits for is still not done */
584
+ async function stillWaiting(waiting) {
585
+ if (typeof kit.backgroundStatus !== 'function') return false;
586
+ for (const name of waiting.waitsFor) {
587
+ const status = await kit.backgroundStatus(name);
588
+ if (status?.status !== 'completed' || status.direction === 'revert') return true;
589
+ }
590
+ return false;
591
+ }
592
+
464
593
  /**
465
594
  * Put a job that a shutdown stopped before it started its work back at the
466
595
  * head of the queue, and return the error that tells BullMQ so — or
@@ -500,7 +629,14 @@ function createMigrationProcessor(options = {}) {
500
629
  }
501
630
 
502
631
  async function handle(job, token, signal) {
503
- const ctx = { job, data: undefined, runId: undefined, started: false, writes: new Set() };
632
+ const ctx = {
633
+ job,
634
+ data: undefined,
635
+ runId: undefined,
636
+ started: false,
637
+ writes: new Set(),
638
+ registered: [],
639
+ };
504
640
  const startedAt = Date.now();
505
641
  const signals = [shutdownController.signal];
506
642
  if (signal) signals.push(signal);
@@ -600,9 +736,11 @@ function createMigrationProcessor(options = {}) {
600
736
 
601
737
  module.exports = {
602
738
  RETRYABLE_CODES,
739
+ UNRECOVERABLE_ERROR_NAME,
603
740
  WAITING_ERROR_NAME,
604
741
  createMigrationProcessor,
605
742
  isTransientForJob,
606
743
  isRetryableError,
744
+ prepareErrorForQueue,
607
745
  resolveProcessorOptions,
608
746
  };