@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +68 -10
  3. package/bullmq.d.ts +465 -7
  4. package/index.d.ts +1272 -18
  5. package/migronaut.schema.json +150 -2
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +153 -15
  11. package/src/bullmq/producer.js +202 -27
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/converge.js +38 -10
  15. package/src/cli/commands/create.js +6 -0
  16. package/src/cli/exit-codes.js +6 -0
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/table.js +68 -9
  19. package/src/core/audit.js +98 -3
  20. package/src/core/background-audit.js +139 -0
  21. package/src/core/background-drift.js +126 -0
  22. package/src/core/background-dry-run.js +366 -0
  23. package/src/core/background-engine.js +818 -0
  24. package/src/core/background-kit.js +425 -0
  25. package/src/core/background-partition.js +298 -0
  26. package/src/core/background-runner.js +305 -0
  27. package/src/core/background-sandbox.js +701 -0
  28. package/src/core/background-shard.js +542 -0
  29. package/src/core/background-spec.js +597 -0
  30. package/src/core/background-store.js +951 -0
  31. package/src/core/background-throttle.js +269 -0
  32. package/src/core/background-watch-plan.js +164 -0
  33. package/src/core/background-watch-store.js +78 -0
  34. package/src/core/background-watch.js +605 -0
  35. package/src/core/background.js +1121 -0
  36. package/src/core/bson-peer.js +23 -0
  37. package/src/core/changelog.js +32 -0
  38. package/src/core/collections.js +125 -31
  39. package/src/core/config.js +133 -13
  40. package/src/core/converge-plan.js +343 -61
  41. package/src/core/converge-search-run.js +440 -0
  42. package/src/core/converge-search.js +404 -0
  43. package/src/core/converge.js +428 -183
  44. package/src/core/index-spec.js +27 -16
  45. package/src/core/lock.js +97 -32
  46. package/src/core/migrator.js +951 -26
  47. package/src/core/options.js +32 -1
  48. package/src/core/run.js +26 -12
  49. package/src/core/runner.js +1 -1
  50. package/src/core/search-index-spec.js +758 -0
  51. package/src/core/server-info.js +70 -0
  52. package/src/core/shard-info.js +76 -0
  53. package/src/core/versioning-spec.js +181 -0
  54. package/src/errors/index.js +97 -5
  55. package/src/index.js +16 -0
  56. package/src/utils/canonical.js +34 -1
  57. package/src/utils/error.js +11 -2
  58. package/src/utils/loader.js +77 -9
  59. package/src/utils/migration-name.js +33 -1
  60. package/src/utils/telemetry.js +125 -1
  61. package/src/utils/template.js +69 -1
  62. package/src/versioning/config.js +155 -0
  63. package/src/versioning/document.js +326 -0
  64. package/src/versioning/index.js +50 -0
  65. package/src/versioning/internal.js +279 -0
  66. package/src/versioning/mongoose.js +151 -0
  67. package/src/versioning/occ.js +318 -0
  68. package/src/versioning/registry.js +187 -0
  69. package/src/versioning/upcaster.js +213 -0
  70. package/versioning.d.ts +666 -0
  71. package/versioning.js +1 -0
@@ -1,14 +1,21 @@
1
- const { ConfigInvalidError, MigrationBlockedError } = require('../errors/index.js');
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
- isPlainObject,
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 (!isPlainObject(jobOptions)) {
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 !== 'applied' || force) {
129
- migrations.push(row.file);
130
- if (typeof row.checksum === 'string') checksums.set(row.file, row.checksum);
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
- if (converge && !(await kit.converge({ dryRun: true })).inSync) {
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.map(() => false);
256
- // A first deploy can enqueue hundreds of jobs: read them back a few at a time.
257
- const stored = await mapLimit(ids, LOOKUP_CONCURRENCY, (id) => queue.getJob(id));
258
- return stored.map((job) => Boolean(job && job.data?.groupId !== groupId));
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
- jobs = plan.migrations.map((migration, index) => ({
303
- id: String(added[index].id),
304
- migration,
305
- index,
306
- }));
307
- const foreign = await foreignJobs(
308
- queue,
309
- added.map((job) => String(job.id)),
310
- groupId,
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
- upToDate: jobs.length === 0,
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 (!isPlainObject(options)) {
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,