@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/producer.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
BackgroundPendingError,
|
|
3
|
+
ConfigInvalidError,
|
|
4
|
+
MigrationBlockedError,
|
|
5
|
+
NotAppliedError,
|
|
6
|
+
} = require('../errors/index.js');
|
|
2
7
|
const { actorIssue, pickActor } = require('../utils/actor.js');
|
|
3
8
|
const { mapLimit } = require('../utils/concurrency.js');
|
|
4
9
|
const { assertId, randomId } = require('../utils/id.js');
|
|
5
10
|
const { assertMigrationName } = require('../utils/migration-name.js');
|
|
6
11
|
const {
|
|
12
|
+
BACKGROUND_FORBIDDEN_JOB_OPTIONS,
|
|
7
13
|
FORBIDDEN_JOB_OPTIONS,
|
|
8
14
|
JOB_NAMES,
|
|
15
|
+
buildBackgroundJob,
|
|
9
16
|
buildConvergeJob,
|
|
10
17
|
buildMigrationJob,
|
|
11
|
-
|
|
18
|
+
isObjectLike,
|
|
12
19
|
migrationJobOptions,
|
|
13
20
|
} = require('./jobs.js');
|
|
14
21
|
const { waitForGroup } = require('./wait.js');
|
|
@@ -24,7 +31,7 @@ const LOOKUP_CONCURRENCY = 16;
|
|
|
24
31
|
*/
|
|
25
32
|
function assertJobOptions(jobOptions) {
|
|
26
33
|
if (jobOptions === undefined) return;
|
|
27
|
-
if (!
|
|
34
|
+
if (!isObjectLike(jobOptions)) {
|
|
28
35
|
throw new ConfigInvalidError('jobOptions must be an object', { jobOptions: typeof jobOptions });
|
|
29
36
|
}
|
|
30
37
|
for (const key of FORBIDDEN_JOB_OPTIONS) {
|
|
@@ -38,6 +45,27 @@ function assertJobOptions(jobOptions) {
|
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A background queue's `jobOptions`: the same passthrough, minus what decides
|
|
50
|
+
* how its coordinators and lanes are retried, ordered and woken.
|
|
51
|
+
*/
|
|
52
|
+
function assertBackgroundJobOptions(jobOptions) {
|
|
53
|
+
if (jobOptions === undefined) return;
|
|
54
|
+
if (!isObjectLike(jobOptions)) {
|
|
55
|
+
throw new ConfigInvalidError('background jobOptions must be an object', {
|
|
56
|
+
jobOptions: typeof jobOptions,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const key of BACKGROUND_FORBIDDEN_JOB_OPTIONS) {
|
|
60
|
+
if (jobOptions[key] !== undefined) {
|
|
61
|
+
throw new ConfigInvalidError(
|
|
62
|
+
`background jobOptions.${key} is not configurable — coordinators and lanes set their own`,
|
|
63
|
+
{ key },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
/** Validate the `requestedBy` / `reason` of an enqueue call, and return them */
|
|
42
70
|
function actorOf(options) {
|
|
43
71
|
for (const key of ['requestedBy', 'reason']) {
|
|
@@ -124,16 +152,38 @@ async function planUpJobs(kit, options = {}) {
|
|
|
124
152
|
const migrations = [];
|
|
125
153
|
/** The version of each file the plan was made from — a worker refuses any other */
|
|
126
154
|
const checksums = new Map();
|
|
155
|
+
/**
|
|
156
|
+
* The first migration that requires a background migration not completed
|
|
157
|
+
* yet — the plan ends before it. (A background file that requires one is
|
|
158
|
+
* not held: it registers as blocked, and starts once it is unblocked.)
|
|
159
|
+
*/
|
|
160
|
+
let waiting;
|
|
127
161
|
for (const row of rows) {
|
|
128
|
-
if (row.status
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
if (row.status === 'applied' && !force) continue;
|
|
163
|
+
if (row.kind !== 'background' && Array.isArray(row.waitsFor) && row.waitsFor.length > 0) {
|
|
164
|
+
waiting = { migration: row.file, waitsFor: row.waitsFor };
|
|
165
|
+
break;
|
|
131
166
|
}
|
|
167
|
+
migrations.push(row.file);
|
|
168
|
+
if (typeof row.checksum === 'string') checksums.set(row.file, row.checksum);
|
|
169
|
+
}
|
|
170
|
+
if (waiting !== undefined && filename !== undefined) {
|
|
171
|
+
// Asked for by name: say why now, rather than enqueue a job that can only fail.
|
|
172
|
+
throw new BackgroundPendingError(
|
|
173
|
+
`${waiting.migration} requires background migration(s) that have not completed: ` +
|
|
174
|
+
waiting.waitsFor.join(', '),
|
|
175
|
+
{
|
|
176
|
+
migration: waiting.migration,
|
|
177
|
+
waitsFor: waiting.waitsFor.map((migration) => ({ migration })),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
132
180
|
}
|
|
133
181
|
const groupId = await newGroupId(kit);
|
|
182
|
+
const tail = waiting !== undefined ? { waiting } : {};
|
|
134
183
|
if (migrations.length === 0) {
|
|
135
|
-
const plan = { groupId, direction: JOB_NAMES.UP, batch: null, migrations, jobs: [] };
|
|
136
|
-
|
|
184
|
+
const plan = { groupId, direction: JOB_NAMES.UP, batch: null, migrations, jobs: [], ...tail };
|
|
185
|
+
// A group cut short of the head never converges — nor does it with nothing to run.
|
|
186
|
+
if (waiting === undefined && converge && !(await kit.converge({ dryRun: true })).inSync) {
|
|
137
187
|
plan.converge = buildConvergeJob({ groupId, ordered, jobOptions, ...actor });
|
|
138
188
|
}
|
|
139
189
|
return plan;
|
|
@@ -158,8 +208,8 @@ async function planUpJobs(kit, options = {}) {
|
|
|
158
208
|
opts: migrationJobOptions(jobOptions, JOB_NAMES.UP, migration, { force }),
|
|
159
209
|
});
|
|
160
210
|
}
|
|
161
|
-
const plan = { groupId, direction: JOB_NAMES.UP, batch, migrations, jobs };
|
|
162
|
-
if (converge) {
|
|
211
|
+
const plan = { groupId, direction: JOB_NAMES.UP, batch, migrations, jobs, ...tail };
|
|
212
|
+
if (converge && waiting === undefined) {
|
|
163
213
|
plan.converge = buildConvergeJob({
|
|
164
214
|
groupId,
|
|
165
215
|
ordered,
|
|
@@ -252,10 +302,13 @@ async function planDownJobs(kit, options = {}) {
|
|
|
252
302
|
* that will do the work. All false on a queue that cannot read jobs back.
|
|
253
303
|
*/
|
|
254
304
|
async function foreignJobs(queue, ids, groupId) {
|
|
255
|
-
if (typeof queue.getJob !== 'function') return ids.
|
|
256
|
-
// A first deploy can enqueue hundreds of jobs: read them back a few at a time
|
|
257
|
-
|
|
258
|
-
return
|
|
305
|
+
if (typeof queue.getJob !== 'function') return new Array(ids.length).fill(false);
|
|
306
|
+
// A first deploy can enqueue hundreds of jobs: read them back a few at a time,
|
|
307
|
+
// each answered as it arrives.
|
|
308
|
+
return mapLimit(ids, LOOKUP_CONCURRENCY, async (id) => {
|
|
309
|
+
const job = await queue.getJob(id);
|
|
310
|
+
return Boolean(job && job.data?.groupId !== groupId);
|
|
311
|
+
});
|
|
259
312
|
}
|
|
260
313
|
|
|
261
314
|
/**
|
|
@@ -299,16 +352,16 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
|
|
|
299
352
|
expected: specs.length,
|
|
300
353
|
});
|
|
301
354
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
355
|
+
// The ids of every job added, and the migration ones as jobs — one pass.
|
|
356
|
+
const ids = new Array(added.length);
|
|
357
|
+
jobs = new Array(plan.migrations.length);
|
|
358
|
+
for (const [index, job] of added.entries()) {
|
|
359
|
+
ids[index] = String(job.id);
|
|
360
|
+
if (index < jobs.length) {
|
|
361
|
+
jobs[index] = { id: ids[index], migration: plan.migrations[index], index };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const foreign = await foreignJobs(queue, ids, groupId);
|
|
312
365
|
for (const job of jobs) {
|
|
313
366
|
if (foreign[job.index]) deduplicated.push(job.migration);
|
|
314
367
|
}
|
|
@@ -332,12 +385,22 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
|
|
|
332
385
|
);
|
|
333
386
|
}
|
|
334
387
|
|
|
388
|
+
if (plan.waiting !== undefined) {
|
|
389
|
+
kit.logger.info(
|
|
390
|
+
`⧗ ${plan.waiting.migration} waits for background migration(s): ` +
|
|
391
|
+
`${plan.waiting.waitsFor.join(', ')} — not enqueued yet`,
|
|
392
|
+
{ groupId, migration: plan.waiting.migration, waitsFor: plan.waiting.waitsFor.length },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
335
396
|
return {
|
|
336
397
|
groupId,
|
|
337
398
|
direction,
|
|
338
399
|
batch,
|
|
339
|
-
// "No migration to run" — a converge-only group is still up to date
|
|
340
|
-
|
|
400
|
+
// "No migration to run" — a converge-only group is still up to date; one
|
|
401
|
+
// cut short by a background migration is not.
|
|
402
|
+
upToDate: jobs.length === 0 && plan.waiting === undefined,
|
|
403
|
+
...(plan.waiting !== undefined ? { waiting: plan.waiting } : {}),
|
|
341
404
|
jobs,
|
|
342
405
|
deduplicated,
|
|
343
406
|
converge,
|
|
@@ -356,7 +419,7 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
|
|
|
356
419
|
* pending — the declared state describes the newest schema.
|
|
357
420
|
*/
|
|
358
421
|
async function enqueueConverge(queue, kit, options = {}, internals = {}) {
|
|
359
|
-
if (!
|
|
422
|
+
if (!isObjectLike(options)) {
|
|
360
423
|
throw new ConfigInvalidError('enqueueConverge options must be an object');
|
|
361
424
|
}
|
|
362
425
|
const { ordered, jobOptions, queueEvents } = options;
|
|
@@ -405,6 +468,115 @@ async function enqueueUp(queue, kit, options = {}, internals = {}) {
|
|
|
405
468
|
});
|
|
406
469
|
}
|
|
407
470
|
|
|
471
|
+
/** How long a running background migration may show no sign of life before a takeover */
|
|
472
|
+
const DEFAULT_STALL_MS = 15 * 60_000;
|
|
473
|
+
|
|
474
|
+
/** The latest sign of life of a background migration: a checkpoint, a coordinator step, its start */
|
|
475
|
+
function lastActivity(status) {
|
|
476
|
+
let latest = 0;
|
|
477
|
+
for (const at of [
|
|
478
|
+
status.lastProgressAt,
|
|
479
|
+
status.coordinator?.at,
|
|
480
|
+
status.startedAt,
|
|
481
|
+
status.registeredAt,
|
|
482
|
+
]) {
|
|
483
|
+
const time = at instanceof Date ? at.getTime() : 0;
|
|
484
|
+
if (time > latest) latest = time;
|
|
485
|
+
}
|
|
486
|
+
return latest;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Enqueue the coordinator of one background migration — or, with no
|
|
491
|
+
* `migration`, of every one with work to do (unblocking those whose requires
|
|
492
|
+
* are met on the way). Safe to call from every pod, as often as you like: a
|
|
493
|
+
* coordinator chain that is alive absorbs the add. A background migration
|
|
494
|
+
* nothing has moved for `stallMs` (no live lease, no checkpoint, no
|
|
495
|
+
* coordinator step) also gets a takeover coordinator — one per round, however
|
|
496
|
+
* many pods ask — whose newer round retires the stuck one.
|
|
497
|
+
*/
|
|
498
|
+
/** Every option enqueueBackground takes — a typo (`stalMs`) is refused, not a silent default */
|
|
499
|
+
const ENQUEUE_BACKGROUND_KEYS = new Set([
|
|
500
|
+
'migration',
|
|
501
|
+
'jobOptions',
|
|
502
|
+
'stallMs',
|
|
503
|
+
'requestedBy',
|
|
504
|
+
'reason',
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
async function enqueueBackground(queue, kit, options = {}) {
|
|
508
|
+
if (!isObjectLike(options)) {
|
|
509
|
+
throw new ConfigInvalidError('enqueueBackground options must be an object');
|
|
510
|
+
}
|
|
511
|
+
for (const key of Object.keys(options)) {
|
|
512
|
+
if (!ENQUEUE_BACKGROUND_KEYS.has(key)) {
|
|
513
|
+
throw new ConfigInvalidError(`enqueueBackground: "${key}" is not an option`, { key });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const { migration, jobOptions, stallMs = DEFAULT_STALL_MS } = options;
|
|
517
|
+
assertQueue(queue);
|
|
518
|
+
assertBackgroundJobOptions(jobOptions);
|
|
519
|
+
if (!Number.isSafeInteger(stallMs) || stallMs < 1000) {
|
|
520
|
+
throw new ConfigInvalidError('stallMs must be an integer of at least 1000', { stallMs });
|
|
521
|
+
}
|
|
522
|
+
const actor = actorOf(options);
|
|
523
|
+
const targets = [];
|
|
524
|
+
if (migration !== undefined) {
|
|
525
|
+
assertMigrationName(migration);
|
|
526
|
+
const status = await kit.backgroundStatus(migration);
|
|
527
|
+
if (status === null) {
|
|
528
|
+
throw new NotAppliedError(
|
|
529
|
+
`Background migration ${migration} is not registered — run up first`,
|
|
530
|
+
{ migration },
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
targets.push(status);
|
|
534
|
+
} else {
|
|
535
|
+
// One read for all of them: the runnable list carries what a stall is told by.
|
|
536
|
+
targets.push(...(await kit.runnableBackground()));
|
|
537
|
+
}
|
|
538
|
+
const specs = [];
|
|
539
|
+
const now = Date.now();
|
|
540
|
+
for (const status of targets) {
|
|
541
|
+
specs.push(buildBackgroundJob({ migration: status.migration, jobOptions, ...actor }));
|
|
542
|
+
const stalled =
|
|
543
|
+
status.status === 'running' &&
|
|
544
|
+
status.liveLeases === 0 &&
|
|
545
|
+
now - lastActivity(status) > stallMs;
|
|
546
|
+
if (stalled) {
|
|
547
|
+
// An operational event: nothing has moved it for stallMs.
|
|
548
|
+
kit.logger.warn(
|
|
549
|
+
`⚠ Background migration ${status.migration} looks stalled (nothing moved it for ` +
|
|
550
|
+
`${Math.round((now - lastActivity(status)) / 1000)}s) — a takeover coordinator is queued`,
|
|
551
|
+
{
|
|
552
|
+
background: status.migration,
|
|
553
|
+
round: status.coordinator?.round ?? 0,
|
|
554
|
+
idleMs: now - lastActivity(status),
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
specs.push(
|
|
558
|
+
buildBackgroundJob({
|
|
559
|
+
migration: status.migration,
|
|
560
|
+
takeoverOf: status.coordinator?.round ?? 0,
|
|
561
|
+
jobOptions,
|
|
562
|
+
...actor,
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (specs.length === 0) return { jobs: [] };
|
|
568
|
+
const added = await queue.addBulk(specs);
|
|
569
|
+
const jobs = [];
|
|
570
|
+
for (const [index, job] of added.entries()) {
|
|
571
|
+
jobs.push({
|
|
572
|
+
migration: specs[index].data.migration,
|
|
573
|
+
id: String(job.id),
|
|
574
|
+
...(specs[index].data.takeover ? { takeover: true } : {}),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return { jobs };
|
|
578
|
+
}
|
|
579
|
+
|
|
408
580
|
/** Enqueue a rollback (last batch, a `batch`, `steps`, back `to`, or one `filename`) */
|
|
409
581
|
async function enqueueDown(queue, kit, options = {}, internals = {}) {
|
|
410
582
|
const { queueEvents, ...planOptions } = options;
|
|
@@ -415,7 +587,10 @@ async function enqueueDown(queue, kit, options = {}, internals = {}) {
|
|
|
415
587
|
}
|
|
416
588
|
|
|
417
589
|
module.exports = {
|
|
590
|
+
DEFAULT_STALL_MS,
|
|
591
|
+
assertBackgroundJobOptions,
|
|
418
592
|
assertJobOptions,
|
|
593
|
+
enqueueBackground,
|
|
419
594
|
enqueueConverge,
|
|
420
595
|
enqueueDown,
|
|
421
596
|
enqueueUp,
|