@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.
- package/CHANGELOG.md +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/src/bullmq/jobs.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
694
|
+
idFragment,
|
|
695
|
+
isObjectLike,
|
|
450
696
|
migrationJobOptions,
|
|
697
|
+
parseBackgroundJobData,
|
|
451
698
|
parseJobData,
|
|
452
699
|
permissionsNeeded,
|
|
453
700
|
resolveAllow,
|