@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.
- package/CHANGELOG.md +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- 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 +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- 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/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- 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 +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- 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 +125 -1
- package/src/utils/template.js +69 -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/index.js
CHANGED
|
@@ -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
|
};
|
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;
|
|
@@ -253,18 +273,88 @@ function createMigrationProcessor(options = {}) {
|
|
|
253
273
|
},
|
|
254
274
|
'converge:action': (event) => {
|
|
255
275
|
if (!current) return;
|
|
256
|
-
const 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
|
-
|
|
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.
|
|
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 = {
|
|
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
|
};
|