@alexify/migronaut 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -3
  3. package/bullmq.d.ts +484 -8
  4. package/index.d.ts +1264 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +9 -2
  7. package/src/bullmq/background-processor.js +541 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +348 -21
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +484 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +375 -0
  21. package/src/core/background-engine.js +849 -0
  22. package/src/core/background-kit.js +432 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +610 -0
  33. package/src/core/background.js +1127 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migration-logger.js +279 -0
  42. package/src/core/migrator.js +1027 -22
  43. package/src/core/options.js +36 -0
  44. package/src/core/run-recorder.js +6 -1
  45. package/src/core/run.js +26 -12
  46. package/src/core/runner.js +34 -8
  47. package/src/core/server-info.js +9 -2
  48. package/src/core/shard-info.js +76 -0
  49. package/src/core/versioning-spec.js +181 -0
  50. package/src/errors/index.js +88 -0
  51. package/src/index.js +16 -0
  52. package/src/utils/error.js +11 -2
  53. package/src/utils/job-ref.js +44 -0
  54. package/src/utils/loader.js +77 -9
  55. package/src/utils/migration-name.js +33 -1
  56. package/src/utils/redact.js +140 -3
  57. package/src/utils/telemetry.js +110 -0
  58. package/src/utils/template.js +62 -1
  59. package/src/versioning/config.js +155 -0
  60. package/src/versioning/document.js +326 -0
  61. package/src/versioning/index.js +50 -0
  62. package/src/versioning/internal.js +279 -0
  63. package/src/versioning/mongoose.js +151 -0
  64. package/src/versioning/occ.js +318 -0
  65. package/src/versioning/registry.js +187 -0
  66. package/src/versioning/upcaster.js +213 -0
  67. package/versioning.d.ts +666 -0
  68. 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,
@@ -335,12 +385,22 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
335
385
  );
336
386
  }
337
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
+
338
396
  return {
339
397
  groupId,
340
398
  direction,
341
399
  batch,
342
- // "No migration to run" — a converge-only group is still up to date.
343
- 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 } : {}),
344
404
  jobs,
345
405
  deduplicated,
346
406
  converge,
@@ -359,7 +419,7 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
359
419
  * pending — the declared state describes the newest schema.
360
420
  */
361
421
  async function enqueueConverge(queue, kit, options = {}, internals = {}) {
362
- if (!isPlainObject(options)) {
422
+ if (!isObjectLike(options)) {
363
423
  throw new ConfigInvalidError('enqueueConverge options must be an object');
364
424
  }
365
425
  const { ordered, jobOptions, queueEvents } = options;
@@ -408,6 +468,115 @@ async function enqueueUp(queue, kit, options = {}, internals = {}) {
408
468
  });
409
469
  }
410
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
+
411
580
  /** Enqueue a rollback (last batch, a `batch`, `steps`, back `to`, or one `filename`) */
412
581
  async function enqueueDown(queue, kit, options = {}, internals = {}) {
413
582
  const { queueEvents, ...planOptions } = options;
@@ -418,7 +587,10 @@ async function enqueueDown(queue, kit, options = {}, internals = {}) {
418
587
  }
419
588
 
420
589
  module.exports = {
590
+ DEFAULT_STALL_MS,
591
+ assertBackgroundJobOptions,
421
592
  assertJobOptions,
593
+ enqueueBackground,
422
594
  enqueueConverge,
423
595
  enqueueDown,
424
596
  enqueueUp,