@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.
- package/CHANGELOG.md +107 -0
- package/README.md +33 -2
- package/bullmq.d.ts +449 -6
- package/index.d.ts +1010 -9
- package/migronaut.schema.json +93 -1
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +128 -14
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +480 -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 +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -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 +605 -0
- package/src/core/background.js +1121 -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/migrator.js +904 -12
- package/src/core/options.js +16 -0
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- 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/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +107 -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,
|
package/src/bullmq/processor.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} = require('./
|
|
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 (!
|
|
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 (!
|
|
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;
|
|
@@ -288,6 +308,53 @@ function createMigrationProcessor(options = {}) {
|
|
|
288
308
|
};
|
|
289
309
|
for (const [event, listener] of Object.entries(listeners)) kit.on(event, listener);
|
|
290
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
|
+
|
|
291
358
|
function resultOf(ctx, rows, waitedMs) {
|
|
292
359
|
const { data } = ctx;
|
|
293
360
|
const row = rows[0];
|
|
@@ -319,7 +386,8 @@ function createMigrationProcessor(options = {}) {
|
|
|
319
386
|
|
|
320
387
|
try {
|
|
321
388
|
const { result, waitedMs } = await waitForLock(ctx, attempt, signal);
|
|
322
|
-
|
|
389
|
+
const started = await startBackground(ctx);
|
|
390
|
+
return { ...resultOf(ctx, result, waitedMs), ...(started ? { background: started } : {}) };
|
|
323
391
|
} catch (error) {
|
|
324
392
|
// A duplicate rollback job: the first one already reverted it. Same
|
|
325
393
|
// outcome as a duplicate `up` job, which the kit reports as skipped.
|
|
@@ -415,6 +483,9 @@ function createMigrationProcessor(options = {}) {
|
|
|
415
483
|
};
|
|
416
484
|
}
|
|
417
485
|
|
|
486
|
+
/** What the last sync tick found the line waiting for — `{ migration, waitsFor }` */
|
|
487
|
+
let lastWaiting;
|
|
488
|
+
|
|
418
489
|
async function runSyncJob(ctx) {
|
|
419
490
|
if (!queue) {
|
|
420
491
|
throw new ConfigInvalidError(
|
|
@@ -422,6 +493,9 @@ function createMigrationProcessor(options = {}) {
|
|
|
422
493
|
);
|
|
423
494
|
}
|
|
424
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 } } : {};
|
|
425
499
|
// The cheap probe first: a scheduler ticks far more often than there is
|
|
426
500
|
// anything to do, and planning proper re-reads the whole directory.
|
|
427
501
|
const pending = await kit.list('pending');
|
|
@@ -433,6 +507,7 @@ function createMigrationProcessor(options = {}) {
|
|
|
433
507
|
enqueued: 0,
|
|
434
508
|
upToDate: true,
|
|
435
509
|
migrations: [],
|
|
510
|
+
...backgroundField,
|
|
436
511
|
};
|
|
437
512
|
// With `convergeAfterUp`, a tick that finds no migration still checks
|
|
438
513
|
// the declared collections — a deploy that only changed a definition
|
|
@@ -464,17 +539,34 @@ function createMigrationProcessor(options = {}) {
|
|
|
464
539
|
upToDate: false,
|
|
465
540
|
migrations: [],
|
|
466
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,
|
|
467
558
|
};
|
|
468
559
|
}
|
|
469
560
|
const group = await enqueueUp(queue, kit, {
|
|
470
561
|
...(to !== undefined ? { to } : {}),
|
|
471
562
|
...(jobOptions ? { jobOptions } : {}),
|
|
472
563
|
});
|
|
564
|
+
lastWaiting = group.waiting;
|
|
473
565
|
const migrations = [];
|
|
474
566
|
for (const job of group.jobs) migrations.push(job.migration);
|
|
475
567
|
return {
|
|
476
568
|
kind: 'sync',
|
|
477
|
-
groupId: group.
|
|
569
|
+
groupId: group.jobs.length === 0 ? null : group.groupId,
|
|
478
570
|
batch: group.batch,
|
|
479
571
|
enqueued: group.jobs.length,
|
|
480
572
|
upToDate: group.upToDate,
|
|
@@ -482,9 +574,22 @@ function createMigrationProcessor(options = {}) {
|
|
|
482
574
|
...(group.converge
|
|
483
575
|
? { converge: { jobId: group.converge.id, deduplicated: group.converge.deduplicated } }
|
|
484
576
|
: {}),
|
|
577
|
+
// Waiting is not held: `held` stays the circuit breaker on a failure.
|
|
578
|
+
...(group.waiting ? { waiting: group.waiting } : {}),
|
|
579
|
+
...backgroundField,
|
|
485
580
|
};
|
|
486
581
|
}
|
|
487
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
|
+
|
|
488
593
|
/**
|
|
489
594
|
* Put a job that a shutdown stopped before it started its work back at the
|
|
490
595
|
* head of the queue, and return the error that tells BullMQ so — or
|
|
@@ -524,7 +629,14 @@ function createMigrationProcessor(options = {}) {
|
|
|
524
629
|
}
|
|
525
630
|
|
|
526
631
|
async function handle(job, token, signal) {
|
|
527
|
-
const ctx = {
|
|
632
|
+
const ctx = {
|
|
633
|
+
job,
|
|
634
|
+
data: undefined,
|
|
635
|
+
runId: undefined,
|
|
636
|
+
started: false,
|
|
637
|
+
writes: new Set(),
|
|
638
|
+
registered: [],
|
|
639
|
+
};
|
|
528
640
|
const startedAt = Date.now();
|
|
529
641
|
const signals = [shutdownController.signal];
|
|
530
642
|
if (signal) signals.push(signal);
|
|
@@ -624,9 +736,11 @@ function createMigrationProcessor(options = {}) {
|
|
|
624
736
|
|
|
625
737
|
module.exports = {
|
|
626
738
|
RETRYABLE_CODES,
|
|
739
|
+
UNRECOVERABLE_ERROR_NAME,
|
|
627
740
|
WAITING_ERROR_NAME,
|
|
628
741
|
createMigrationProcessor,
|
|
629
742
|
isTransientForJob,
|
|
630
743
|
isRetryableError,
|
|
744
|
+
prepareErrorForQueue,
|
|
631
745
|
resolveProcessorOptions,
|
|
632
746
|
};
|