@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
@@ -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,