@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
@@ -9,16 +9,19 @@ const {
9
9
  RunAbortedError,
10
10
  } = require('../errors/index.js');
11
11
  const { pickActor } = require('../utils/actor.js');
12
+ const { isPlainObject } = require('../utils/canonical.js');
12
13
  const { errorText } = require('../utils/error.js');
14
+ const { jobRefIssue } = require('../utils/job-ref.js');
13
15
  const { redactDeep, redactOutbound } = require('../utils/redact.js');
16
+ const { sanitize } = require('../utils/sanitize.js');
17
+ const { JOB_NAMES, assertAllowed, isObjectLike, parseJobData, resolveAllow } = require('./jobs.js');
14
18
  const {
15
- JOB_NAMES,
16
- assertAllowed,
17
- isPlainObject,
18
- parseJobData,
19
- resolveAllow,
20
- } = require('./jobs.js');
21
- const { assertJobOptions, enqueueConverge, enqueueUp } = require('./producer.js');
19
+ assertBackgroundJobOptions,
20
+ assertJobOptions,
21
+ enqueueBackground,
22
+ enqueueConverge,
23
+ enqueueUp,
24
+ } = require('./producer.js');
22
25
 
23
26
  /**
24
27
  * Failures a later attempt can get past without anything being fixed: the lock
@@ -79,14 +82,162 @@ function jobIds(ctx) {
79
82
  };
80
83
  }
81
84
 
85
+ /**
86
+ * The job a run works for, as the kit's `job` option — `{ id, groupId? }` (a
87
+ * lane's slice: `{ id }`), or undefined when the job has no id the kit would
88
+ * take (it then logs nothing of the job; the run itself is unaffected). A
89
+ * group the kit would refuse is left out rather than the whole reference.
90
+ */
91
+ function jobRefOf(job, groupId) {
92
+ if (job?.id === undefined || job.id === null) return undefined;
93
+ const id = String(job.id);
94
+ const ref = groupId !== undefined ? { id, groupId } : { id };
95
+ if (jobRefIssue(ref) === null) return ref;
96
+ return jobRefIssue({ id }) === null ? { id } : undefined;
97
+ }
98
+
99
+ /** The longest row a `migration:log` event becomes in a job's log */
100
+ const USERLAND_ROW_MAX = 1024;
101
+
102
+ /**
103
+ * How many `migration:log` rows one job's log takes by default (a lane: one
104
+ * slice) — the rest are counted in one closing row. A migration that logs in
105
+ * a loop must not write tens of thousands of entries into Redis per job.
106
+ */
107
+ const DEFAULT_USERLAND_LOG_ROWS = 1000;
108
+
109
+ /** Line breaks: a row is one line of the job's log, so they are shown, never acted on */
110
+ const ROW_BREAKS = /\r\n|[\r\n\u2028\u2029]/g;
111
+
112
+ /** Text as part of one row: line breaks shown as ⏎, control characters dropped */
113
+ const oneLine = (text) => sanitize(String(text).replace(ROW_BREAKS, '⏎'));
114
+
115
+ /** BigInts have no JSON form of their own; a log row shows their digits */
116
+ const jsonValue = (_key, value) => (typeof value === 'bigint' ? value.toString() : value);
117
+
118
+ /** Stops {@link boundedJson} once the row has what it can show */
119
+ const ROW_FULL = Symbol('row full');
120
+
121
+ /**
122
+ * `data` as JSON, written only until `max` characters — never the whole of
123
+ * it: an event's data may hold a thousand entries of 4 KB each, and the row
124
+ * shows a kilobyte. The JSON of what `JSON.stringify` would write, cut short.
125
+ */
126
+ function boundedJson(data, max) {
127
+ let out = '';
128
+ const push = (text) => {
129
+ if (out.length + text.length > max) {
130
+ out += text.slice(0, max - out.length + 1);
131
+ throw ROW_FULL;
132
+ }
133
+ out += text;
134
+ };
135
+ const write = (value) => {
136
+ if (typeof value === 'bigint') return push(`"${value}"`);
137
+ // Arrays and plain objects are walked here; anything else is a leaf.
138
+ if (!Array.isArray(value) && !isPlainObject(value)) {
139
+ return push(JSON.stringify(value, jsonValue) ?? 'null');
140
+ }
141
+ if (Array.isArray(value)) {
142
+ push('[');
143
+ for (let index = 0; index < value.length; index++) {
144
+ if (index > 0) push(',');
145
+ write(value[index]);
146
+ }
147
+ return push(']');
148
+ }
149
+ push('{');
150
+ let first = true;
151
+ for (const key of Object.keys(value)) {
152
+ const item = value[key];
153
+ if (item === undefined || typeof item === 'function' || typeof item === 'symbol') continue;
154
+ push(`${first ? '' : ','}${JSON.stringify(key)}:`);
155
+ first = false;
156
+ write(item);
157
+ }
158
+ return push('}');
159
+ };
160
+ try {
161
+ write(data);
162
+ } catch (error) {
163
+ if (error !== ROW_FULL) throw error;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Whether `data` has a key to show — without listing them all */
169
+ function hasEntries(data) {
170
+ if (data === null || typeof data !== 'object') return false;
171
+ for (const _key in data) return true;
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * A `migration:log` event as a row of the job's log, next to the processor's
177
+ * own lifecycle rows: `✎ <level: ><msg> <data as JSON>`, the attempt when a
178
+ * transaction was retried, the partition of a background lane. One line —
179
+ * a line break in the message cannot forge a row of its own — cut at
180
+ * {@link USERLAND_ROW_MAX}; redacted on its way out like every row.
181
+ */
182
+ function userlandRow(event) {
183
+ const level = event.level === 'info' ? '' : `${event.level}: `;
184
+ const attempt = event.attempt > 1 ? ` (attempt ${event.attempt})` : '';
185
+ const partition =
186
+ event.kind === 'background' && event.partition ? ` [partition ${event.partition}]` : '';
187
+ const head = `✎ ${level}${event.msg}`;
188
+ let data = '';
189
+ if (hasEntries(event.data) && head.length < USERLAND_ROW_MAX) {
190
+ try {
191
+ data = ` ${boundedJson(event.data, USERLAND_ROW_MAX - head.length)}`;
192
+ } catch {
193
+ data = ' [data not serializable]';
194
+ }
195
+ }
196
+ const row = oneLine(`${head}${data}${attempt}${partition}`);
197
+ return row.length > USERLAND_ROW_MAX ? `${row.slice(0, USERLAND_ROW_MAX - 1)}…` : row;
198
+ }
199
+
200
+ /** The closing row of a job whose userland lines went past the limit */
201
+ function userlandOverflowRow(dropped, limit) {
202
+ return `✎ … ${dropped} more line(s) past the limit of ${limit} not mirrored here — see migration:log`;
203
+ }
204
+
205
+ /** `userlandLogRows`: how many userland rows one job's log takes */
206
+ function assertUserlandLogRows(value) {
207
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
208
+ throw new ConfigInvalidError('userlandLogRows must be an integer of at least 0', {
209
+ userlandLogRows: value,
210
+ });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Whether a `migration:log` event is the job's own. Matched by run and job,
216
+ * not by the job in flight alone: a body that outlived its timeout may still
217
+ * log while the next job runs — the same job again, if it was put back in the
218
+ * queue — and its lines must not land in that run's log. A background
219
+ * migration the run drives inline logs from lanes of its own (their own run
220
+ * ids), named by the job and its group: a background queue's lanes have no
221
+ * group, and their ids may well repeat a migration job's.
222
+ */
223
+ function ownLine(ctx, event) {
224
+ const ref = ctx.jobRef;
225
+ if (event.kind === 'background') {
226
+ return ref?.groupId !== undefined && event.jobId === ref.id && event.groupId === ref.groupId;
227
+ }
228
+ if (event.kind !== 'migration') return false;
229
+ if (ctx.runId === undefined || event.runId !== ctx.runId) return false;
230
+ return ref === undefined || event.jobId === ref.id;
231
+ }
232
+
82
233
  /** A job in a few words, for log lines: `up 20260101-x.js (1/3)`, `converge`, `sync` */
83
234
  function describeJob(data) {
84
235
  if (data.kind !== 'migration') return data.kind;
85
236
  return `${data.direction} ${data.migration} (${data.index + 1}/${data.total})`;
86
237
  }
87
238
 
88
- /** The structured fields of a job's log lines */
89
- function jobFields(data) {
239
+ /** The structured fields of a job's log lines: what the job is about */
240
+ function jobSummary(data) {
90
241
  if (data.kind !== 'migration') return { kind: data.kind };
91
242
  return {
92
243
  migration: data.migration,
@@ -136,10 +287,19 @@ function prepareErrorForQueue(error) {
136
287
  * opens any connection of its own.
137
288
  */
138
289
  function resolveProcessorOptions(options) {
139
- if (!isPlainObject(options)) {
290
+ if (!isObjectLike(options)) {
140
291
  throw new ConfigInvalidError('createMigrationProcessor options must be an object');
141
292
  }
142
- const { kit, config, lockWait = {}, jobOptions, ordered = true, allow } = options;
293
+ const {
294
+ kit,
295
+ config,
296
+ lockWait = {},
297
+ jobOptions,
298
+ ordered = true,
299
+ allow,
300
+ background,
301
+ userlandLogRows = DEFAULT_USERLAND_LOG_ROWS,
302
+ } = options;
143
303
  if (kit !== undefined && config !== undefined) {
144
304
  throw new ConfigInvalidError('Pass either `kit` or `config`, not both');
145
305
  }
@@ -149,7 +309,7 @@ function resolveProcessorOptions(options) {
149
309
  if (typeof ordered !== 'boolean') {
150
310
  throw new ConfigInvalidError('ordered must be a boolean', { ordered });
151
311
  }
152
- if (!isPlainObject(lockWait)) {
312
+ if (!isObjectLike(lockWait)) {
153
313
  throw new ConfigInvalidError('lockWait must be an object', { lockWait: typeof lockWait });
154
314
  }
155
315
  // Unlike runMigrations, waiting is the default: nothing is blocked on this
@@ -157,7 +317,25 @@ function resolveProcessorOptions(options) {
157
317
  const waitOptions = { onLockHeld: 'wait', ...lockWait };
158
318
  assertLockWaitOptions(waitOptions);
159
319
  assertJobOptions(jobOptions);
160
- return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow) };
320
+ if (background !== undefined) assertBackgroundLink(background);
321
+ assertUserlandLogRows(userlandLogRows);
322
+ return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow), userlandLogRows };
323
+ }
324
+
325
+ /**
326
+ * The background queue a migration processor hands what it registers to:
327
+ * `{ queue, jobOptions?, stallMs? }`.
328
+ */
329
+ function assertBackgroundLink(background) {
330
+ if (!isObjectLike(background) || typeof background.queue?.addBulk !== 'function') {
331
+ throw new ConfigInvalidError('background must be { queue } — the background queue');
332
+ }
333
+ for (const key of Object.keys(background)) {
334
+ if (key !== 'queue' && key !== 'jobOptions' && key !== 'stallMs') {
335
+ throw new ConfigInvalidError(`background.${key} is not an option here`, { key });
336
+ }
337
+ }
338
+ assertBackgroundJobOptions(background.jobOptions);
161
339
  }
162
340
 
163
341
  /**
@@ -172,8 +350,8 @@ function resolveProcessorOptions(options) {
172
350
  * signal only to processors whose `length` is at least 3.
173
351
  */
174
352
  function createMigrationProcessor(options = {}) {
175
- const { waitOptions, defaultOrdered, allow } = resolveProcessorOptions(options);
176
- const { kit: injectedKit, config, kitOptions, queue, jobOptions } = options;
353
+ const { waitOptions, defaultOrdered, allow, userlandLogRows } = resolveProcessorOptions(options);
354
+ const { kit: injectedKit, config, kitOptions, queue, jobOptions, background } = options;
177
355
 
178
356
  const ownsKit = injectedKit === undefined;
179
357
  const kit = injectedKit ?? new MigratorKit(config ?? {}, kitOptions);
@@ -218,6 +396,29 @@ function createMigrationProcessor(options = {}) {
218
396
  );
219
397
  const flush = (ctx) => Promise.allSettled([...ctx.writes]);
220
398
 
399
+ /** A userland line into the job's log — up to `userlandLogRows`, then only counted */
400
+ const mirror = (ctx, event) => {
401
+ if (ctx.userlandRows < userlandLogRows) {
402
+ ctx.userlandRows += 1;
403
+ log(ctx, userlandRow(event));
404
+ } else {
405
+ ctx.userlandDropped += 1;
406
+ }
407
+ };
408
+
409
+ /**
410
+ * Nothing more is written for the migration once the job settles: a row
411
+ * after BullMQ removed the job would leave its log behind in Redis. What
412
+ * went past the limit is said in one last row first.
413
+ */
414
+ const seal = (ctx) => {
415
+ if (ctx.sealed) return;
416
+ if (ctx.userlandDropped > 0) {
417
+ log(ctx, userlandOverflowRow(ctx.userlandDropped, userlandLogRows));
418
+ }
419
+ ctx.sealed = true;
420
+ };
421
+
221
422
  // Subscribed once, for the processor's lifetime: the kit emits per run, and
222
423
  // `current` says which job that run belongs to.
223
424
  const listeners = {
@@ -246,6 +447,9 @@ function createMigrationProcessor(options = {}) {
246
447
  'migration:skipped': (event) => {
247
448
  if (current) log(current, `⏭ Skipped ${event.migration} (${event.reason ?? 'skipped'})`);
248
449
  },
450
+ 'background:registered': (event) => {
451
+ if (current) current.registered.push(event.migration);
452
+ },
249
453
  'converge:start': () => {
250
454
  if (!current) return;
251
455
  current.started = true;
@@ -285,9 +489,60 @@ function createMigrationProcessor(options = {}) {
285
489
  'converge:end': (event) => {
286
490
  if (current && event.success) log(current, `✔ Converged ${event.changed} change(s)`);
287
491
  },
492
+ // What the migration itself logged for its users — see ownLine.
493
+ 'migration:log': (event) => {
494
+ if (current && !current.sealed && ownLine(current, event)) mirror(current, event);
495
+ },
288
496
  };
289
497
  for (const [event, listener] of Object.entries(listeners)) kit.on(event, listener);
290
498
 
499
+ /** The background queue options every enqueue from here shares */
500
+ const backgroundOptions = () => ({
501
+ ...(background.jobOptions !== undefined ? { jobOptions: background.jobOptions } : {}),
502
+ ...(background.stallMs !== undefined ? { stallMs: background.stallMs } : {}),
503
+ });
504
+
505
+ /**
506
+ * Hand what this job registered to the background queue. Never fails the
507
+ * job — it is applied; a coordinator that could not be added now is added
508
+ * by the next heal (a sync tick, a verify tick, a worker's start).
509
+ */
510
+ async function startBackground(ctx) {
511
+ if (background === undefined || ctx.registered.length === 0) return undefined;
512
+ const started = [];
513
+ for (const migration of ctx.registered) {
514
+ try {
515
+ const { jobs } = await enqueueBackground(background.queue, kit, {
516
+ migration,
517
+ ...backgroundOptions(),
518
+ });
519
+ for (const job of jobs) started.push({ migration, jobId: job.id });
520
+ } catch (error) {
521
+ kit.logger.warn(
522
+ `⚠ Could not enqueue background migration ${migration}: ${errorText(error)} — ` +
523
+ 'the next heal will',
524
+ { ...jobIds(ctx), migration, error: errorText(error) },
525
+ );
526
+ }
527
+ }
528
+ for (const entry of started) log(ctx, `⧗ Background coordinator ${entry.jobId} enqueued`);
529
+ return started;
530
+ }
531
+
532
+ /** Every background migration with work to do gets its coordinator — a sync tick's heal */
533
+ async function healBackground() {
534
+ if (background === undefined) return undefined;
535
+ try {
536
+ const { jobs } = await enqueueBackground(background.queue, kit, backgroundOptions());
537
+ return jobs.length;
538
+ } catch (error) {
539
+ kit.logger.warn(`⚠ Background heal failed: ${errorText(error)}`, {
540
+ error: errorText(error),
541
+ });
542
+ return 0;
543
+ }
544
+ }
545
+
291
546
  function resultOf(ctx, rows, waitedMs) {
292
547
  const { data } = ctx;
293
548
  const row = rows[0];
@@ -306,6 +561,15 @@ function createMigrationProcessor(options = {}) {
306
561
  async function runMigrationJob(ctx, signal) {
307
562
  const { data } = ctx;
308
563
  const ordered = data.ordered ?? defaultOrdered;
564
+ // The run's correlation names this job, so what the migration logs can be
565
+ // joined to it (and its userland lines routed into its log).
566
+ ctx.jobRef = jobRefOf(ctx.job, data.groupId);
567
+ if (ctx.jobRef === undefined) {
568
+ kit.logger.debug(`Job ${ctx.job?.id} has no id the run can carry — its lines name no job`, {
569
+ ...jobIds(ctx),
570
+ });
571
+ }
572
+ const jobField = ctx.jobRef ? { job: ctx.jobRef } : {};
309
573
  const attempt = () =>
310
574
  data.direction === JOB_NAMES.UP
311
575
  ? kit.up(data.migration, {
@@ -314,12 +578,18 @@ function createMigrationProcessor(options = {}) {
314
578
  ...(data.force ? { force: true } : {}),
315
579
  ...(data.checksum ? { checksum: data.checksum } : {}),
316
580
  ...pickActor(data),
581
+ ...jobField,
317
582
  })
318
- : kit.down(data.migration, { ...(ordered ? { ordered: true } : {}), ...pickActor(data) });
583
+ : kit.down(data.migration, {
584
+ ...(ordered ? { ordered: true } : {}),
585
+ ...pickActor(data),
586
+ ...jobField,
587
+ });
319
588
 
320
589
  try {
321
590
  const { result, waitedMs } = await waitForLock(ctx, attempt, signal);
322
- return resultOf(ctx, result, waitedMs);
591
+ const started = await startBackground(ctx);
592
+ return { ...resultOf(ctx, result, waitedMs), ...(started ? { background: started } : {}) };
323
593
  } catch (error) {
324
594
  // A duplicate rollback job: the first one already reverted it. Same
325
595
  // outcome as a duplicate `up` job, which the kit reports as skipped.
@@ -415,6 +685,9 @@ function createMigrationProcessor(options = {}) {
415
685
  };
416
686
  }
417
687
 
688
+ /** What the last sync tick found the line waiting for — `{ migration, waitsFor }` */
689
+ let lastWaiting;
690
+
418
691
  async function runSyncJob(ctx) {
419
692
  if (!queue) {
420
693
  throw new ConfigInvalidError(
@@ -422,6 +695,9 @@ function createMigrationProcessor(options = {}) {
422
695
  );
423
696
  }
424
697
  const { to } = ctx.data;
698
+ // Background migrations first: whatever this tick enqueues may wait for one.
699
+ const healed = await healBackground();
700
+ const backgroundField = healed !== undefined ? { background: { enqueued: healed } } : {};
425
701
  // The cheap probe first: a scheduler ticks far more often than there is
426
702
  // anything to do, and planning proper re-reads the whole directory.
427
703
  const pending = await kit.list('pending');
@@ -433,6 +709,7 @@ function createMigrationProcessor(options = {}) {
433
709
  enqueued: 0,
434
710
  upToDate: true,
435
711
  migrations: [],
712
+ ...backgroundField,
436
713
  };
437
714
  // With `convergeAfterUp`, a tick that finds no migration still checks
438
715
  // the declared collections — a deploy that only changed a definition
@@ -464,17 +741,34 @@ function createMigrationProcessor(options = {}) {
464
741
  upToDate: false,
465
742
  migrations: [],
466
743
  held,
744
+ ...backgroundField,
745
+ };
746
+ }
747
+ // Still waiting where the last tick found it waiting, for what is still
748
+ // not done: said again without planning — a background migration takes
749
+ // hours, and planning re-reads the whole directory on every tick.
750
+ if (lastWaiting?.migration === pending[0].file && (await stillWaiting(lastWaiting))) {
751
+ return {
752
+ kind: 'sync',
753
+ groupId: null,
754
+ batch: null,
755
+ enqueued: 0,
756
+ upToDate: false,
757
+ migrations: [],
758
+ waiting: lastWaiting,
759
+ ...backgroundField,
467
760
  };
468
761
  }
469
762
  const group = await enqueueUp(queue, kit, {
470
763
  ...(to !== undefined ? { to } : {}),
471
764
  ...(jobOptions ? { jobOptions } : {}),
472
765
  });
766
+ lastWaiting = group.waiting;
473
767
  const migrations = [];
474
768
  for (const job of group.jobs) migrations.push(job.migration);
475
769
  return {
476
770
  kind: 'sync',
477
- groupId: group.upToDate ? null : group.groupId,
771
+ groupId: group.jobs.length === 0 ? null : group.groupId,
478
772
  batch: group.batch,
479
773
  enqueued: group.jobs.length,
480
774
  upToDate: group.upToDate,
@@ -482,9 +776,22 @@ function createMigrationProcessor(options = {}) {
482
776
  ...(group.converge
483
777
  ? { converge: { jobId: group.converge.id, deduplicated: group.converge.deduplicated } }
484
778
  : {}),
779
+ // Waiting is not held: `held` stays the circuit breaker on a failure.
780
+ ...(group.waiting ? { waiting: group.waiting } : {}),
781
+ ...backgroundField,
485
782
  };
486
783
  }
487
784
 
785
+ /** Whether a background migration `waiting` waits for is still not done */
786
+ async function stillWaiting(waiting) {
787
+ if (typeof kit.backgroundStatus !== 'function') return false;
788
+ for (const name of waiting.waitsFor) {
789
+ const status = await kit.backgroundStatus(name);
790
+ if (status?.status !== 'completed' || status.direction === 'revert') return true;
791
+ }
792
+ return false;
793
+ }
794
+
488
795
  /**
489
796
  * Put a job that a shutdown stopped before it started its work back at the
490
797
  * head of the queue, and return the error that tells BullMQ so — or
@@ -524,7 +831,17 @@ function createMigrationProcessor(options = {}) {
524
831
  }
525
832
 
526
833
  async function handle(job, token, signal) {
527
- const ctx = { job, data: undefined, runId: undefined, started: false, writes: new Set() };
834
+ const ctx = {
835
+ job,
836
+ data: undefined,
837
+ runId: undefined,
838
+ started: false,
839
+ writes: new Set(),
840
+ registered: [],
841
+ userlandRows: 0,
842
+ userlandDropped: 0,
843
+ sealed: false,
844
+ };
528
845
  const startedAt = Date.now();
529
846
  const signals = [shutdownController.signal];
530
847
  if (signal) signals.push(signal);
@@ -546,22 +863,24 @@ function createMigrationProcessor(options = {}) {
546
863
  abort.addEventListener('abort', onAbort, { once: true });
547
864
  kit.logger.debug(`▶ Job ${job?.id} (${describeJob(ctx.data)})`, {
548
865
  ...jobIds(ctx),
549
- ...jobFields(ctx.data),
866
+ ...jobSummary(ctx.data),
550
867
  });
551
868
  await kit.connect();
552
869
  let result;
553
870
  if (ctx.data.kind === 'sync') result = await runSyncJob(ctx);
554
871
  else if (ctx.data.kind === 'converge') result = await runConvergeJob(ctx, abort);
555
872
  else result = await runMigrationJob(ctx, abort);
873
+ seal(ctx);
556
874
  progress(ctx, 'completed', ctx.runId ? { runId: ctx.runId } : {});
557
875
  await flush(ctx);
558
876
  kit.logger.debug(`✔ Job ${job?.id} done`, {
559
877
  ...jobIds(ctx),
560
- ...jobFields(ctx.data),
878
+ ...jobSummary(ctx.data),
561
879
  durationMs: Date.now() - startedAt,
562
880
  });
563
881
  return result;
564
882
  } catch (error) {
883
+ seal(ctx);
565
884
  const requeued = await requeueOnShutdown(ctx, error, token);
566
885
  if (requeued) throw requeued;
567
886
  // BullMQ only retries when the job was given more than one attempt — the
@@ -624,9 +943,17 @@ function createMigrationProcessor(options = {}) {
624
943
 
625
944
  module.exports = {
626
945
  RETRYABLE_CODES,
946
+ UNRECOVERABLE_ERROR_NAME,
627
947
  WAITING_ERROR_NAME,
948
+ DEFAULT_USERLAND_LOG_ROWS,
949
+ assertUserlandLogRows,
628
950
  createMigrationProcessor,
629
951
  isTransientForJob,
630
952
  isRetryableError,
953
+ jobRefOf,
954
+ ownLine,
955
+ prepareErrorForQueue,
631
956
  resolveProcessorOptions,
957
+ userlandOverflowRow,
958
+ userlandRow,
632
959
  };