@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
@@ -0,0 +1,541 @@
1
+ const { MigratorKit } = require('../core/migrator.js');
2
+ const { ConfigInvalidError, MigronautError, RunAbortedError } = require('../errors/index.js');
3
+ const { errorText } = require('../utils/error.js');
4
+ const { redactOutbound } = require('../utils/redact.js');
5
+ const { JOB_NAMES, buildLaneJob, isObjectLike, parseBackgroundJobData } = require('./jobs.js');
6
+ const {
7
+ DEFAULT_USERLAND_LOG_ROWS,
8
+ UNRECOVERABLE_ERROR_NAME,
9
+ assertUserlandLogRows,
10
+ isRetryableError,
11
+ jobRefOf,
12
+ prepareErrorForQueue,
13
+ userlandOverflowRow,
14
+ userlandRow,
15
+ } = require('./processor.js');
16
+ const {
17
+ DEFAULT_STALL_MS,
18
+ assertBackgroundJobOptions,
19
+ enqueueBackground,
20
+ } = require('./producer.js');
21
+
22
+ /**
23
+ * Background migrations on a queue of their own. A coordinator job per
24
+ * background migration plans its partitions and spawns lanes as its children
25
+ * (`parent` + `moveToWaitingChildren`); each lane works slices, continuing
26
+ * itself with `moveToDelayed` between them, until nothing is left to claim.
27
+ * The coordinator wakes once its last lane is done and decides — from
28
+ * MongoDB, never from how its lanes ended — whether to spawn more, plan
29
+ * another pass or finish. Everything that matters (plans, cursors, leases,
30
+ * counters) is in MongoDB: a lost job or a lost Redis costs a heal, not work.
31
+ *
32
+ * Unlike the migration processor, jobs run side by side here — the kit's
33
+ * background methods are reentrant, and the leases cap the lanes.
34
+ */
35
+
36
+ const DEFAULTS = Object.freeze({
37
+ pollIntervalMs: 5_000,
38
+ maxLaneRetries: 8,
39
+ });
40
+
41
+ /** What BullMQ checks (by name) for a job the processor moved to delayed itself */
42
+ const DELAYED_ERROR_NAME = 'DelayedError';
43
+ /** What BullMQ checks (by name) for a job the processor moved to wait for its children */
44
+ const WAITING_CHILDREN_ERROR_NAME = 'WaitingChildrenError';
45
+ /** The longest a failing lane backs off for */
46
+ const MAX_LANE_BACKOFF_MS = 5 * 60_000;
47
+ /**
48
+ * Coordinator steps one job takes in a row when its lanes all finished before
49
+ * it could wait for them — then it yields the worker and comes back.
50
+ */
51
+ const MAX_INLINE_STEPS = 5;
52
+
53
+ /** The longest slice a caller may ask for — the kit's limit, mirrored (a unit test pins it) */
54
+ const MAX_SLICE_MS = 3_600_000;
55
+
56
+ /**
57
+ * The error that tells BullMQ a job was moved (delayed, waiting for its
58
+ * children) — a typed one, renamed: BullMQ matches the name, and the adapter
59
+ * cannot import its classes.
60
+ */
61
+ const moved = (name) => {
62
+ const error = new RunAbortedError(`Moved: ${name}`, { reason: name, moved: true });
63
+ error.name = name;
64
+ return error;
65
+ };
66
+
67
+ /**
68
+ * Validate the background processor's options. Pure, like the migration
69
+ * processor's: nothing is constructed.
70
+ */
71
+ /** Every option createBackgroundProcessor takes — a typo is refused, not ignored */
72
+ const PROCESSOR_KEYS = new Set([
73
+ 'kit',
74
+ 'config',
75
+ 'kitOptions',
76
+ 'queue',
77
+ 'jobOptions',
78
+ 'sliceMs',
79
+ 'children',
80
+ 'pollIntervalMs',
81
+ 'stallMs',
82
+ 'maxLaneRetries',
83
+ 'userlandLogRows',
84
+ ]);
85
+
86
+ function resolveBackgroundProcessorOptions(options) {
87
+ if (!isObjectLike(options)) {
88
+ throw new ConfigInvalidError('createBackgroundProcessor options must be an object');
89
+ }
90
+ for (const key of Object.keys(options)) {
91
+ if (!PROCESSOR_KEYS.has(key)) {
92
+ throw new ConfigInvalidError(`createBackgroundProcessor: "${key}" is not an option`, { key });
93
+ }
94
+ }
95
+ const {
96
+ kit,
97
+ config,
98
+ queue,
99
+ jobOptions,
100
+ sliceMs,
101
+ children = 'auto',
102
+ pollIntervalMs = DEFAULTS.pollIntervalMs,
103
+ stallMs = DEFAULT_STALL_MS,
104
+ maxLaneRetries = DEFAULTS.maxLaneRetries,
105
+ userlandLogRows = DEFAULT_USERLAND_LOG_ROWS,
106
+ } = options;
107
+ if (kit !== undefined && config !== undefined) {
108
+ throw new ConfigInvalidError('Pass either `kit` or `config`, not both');
109
+ }
110
+ if (kit !== undefined && typeof kit?.coordinateBackground !== 'function') {
111
+ throw new ConfigInvalidError('kit must be a MigratorKit instance');
112
+ }
113
+ if (!queue || typeof queue.addBulk !== 'function') {
114
+ throw new ConfigInvalidError(
115
+ 'queue is required — the background queue the coordinators add their lanes to',
116
+ );
117
+ }
118
+ // The kit's own range for a caller's slice (background-spec's assertSliceMs).
119
+ if (
120
+ sliceMs !== undefined &&
121
+ (!Number.isSafeInteger(sliceMs) || sliceMs < 1 || sliceMs > MAX_SLICE_MS)
122
+ ) {
123
+ throw new ConfigInvalidError(`sliceMs must be an integer from 1 to ${MAX_SLICE_MS}`, {
124
+ sliceMs,
125
+ });
126
+ }
127
+ if (children !== 'auto' && children !== false) {
128
+ throw new ConfigInvalidError("children must be 'auto' or false", { children });
129
+ }
130
+ if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 10) {
131
+ throw new ConfigInvalidError('pollIntervalMs must be an integer ≥ 10', { pollIntervalMs });
132
+ }
133
+ if (!Number.isSafeInteger(stallMs) || stallMs < 1000) {
134
+ throw new ConfigInvalidError('stallMs must be an integer of at least 1000', { stallMs });
135
+ }
136
+ if (!Number.isSafeInteger(maxLaneRetries) || maxLaneRetries < 0 || maxLaneRetries > 100) {
137
+ throw new ConfigInvalidError('maxLaneRetries must be an integer from 0 to 100', {
138
+ maxLaneRetries,
139
+ });
140
+ }
141
+ assertBackgroundJobOptions(jobOptions);
142
+ assertUserlandLogRows(userlandLogRows);
143
+ return { sliceMs, children, pollIntervalMs, stallMs, maxLaneRetries, userlandLogRows };
144
+ }
145
+
146
+ /**
147
+ * Build the function a BullMQ Worker on the background queue runs. Declared
148
+ * with exactly three parameters, so BullMQ hands it the cancellation signal.
149
+ */
150
+ function createBackgroundProcessor(options = {}) {
151
+ const settings = resolveBackgroundProcessorOptions(options);
152
+ const { kit: injectedKit, config, kitOptions, queue, jobOptions } = options;
153
+ const ownsKit = injectedKit === undefined;
154
+ const kit = injectedKit ?? new MigratorKit(config ?? {}, kitOptions);
155
+ const shutdownController = new AbortController();
156
+ const inFlight = new Set();
157
+ let warnedUnmovable = false;
158
+ /**
159
+ * The lanes working a slice in this process, by job id — lanes run side by
160
+ * side, so a `migration:log` event's `jobId` is what says whose log it goes
161
+ * to. `writes` are its rows still in flight, drained when the slice ends;
162
+ * `rows`/`dropped` count them against `userlandLogRows`, per slice.
163
+ */
164
+ const lanes = new Map();
165
+
166
+ /** A log row on the job — never allowed to fail it */
167
+ async function log(job, row) {
168
+ try {
169
+ await job.log?.(redactOutbound(row));
170
+ } catch {
171
+ // Redis is the job's problem, not the background migration's.
172
+ }
173
+ }
174
+
175
+ /** The job's progress, for a dashboard — never allowed to fail it either */
176
+ async function progress(job, value) {
177
+ try {
178
+ await job.updateProgress?.(value);
179
+ } catch {
180
+ // As above.
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Continue this job later: its data updated first (when `data` is given),
186
+ * then moved to delayed — which BullMQ learns from the error's name. Outside
187
+ * a Worker (no token) there is nothing to move: the outcome is returned.
188
+ */
189
+ async function later(ctx, delayMs, result, data) {
190
+ const { job, token } = ctx;
191
+ if (typeof job.moveToDelayed !== 'function' || typeof token !== 'string') {
192
+ if (!warnedUnmovable) {
193
+ warnedUnmovable = true;
194
+ kit.logger.warn(
195
+ '⚠ Background jobs run outside a BullMQ Worker (no token to move them with): a ' +
196
+ 'coordinator takes one step and a lane one slice per job, and the rest waits for ' +
197
+ 'the next heal',
198
+ {},
199
+ );
200
+ }
201
+ return { ...result, retryAfterMs: delayMs };
202
+ }
203
+ if (data !== undefined) await job.updateData(data);
204
+ await job.moveToDelayed(Date.now() + Math.max(0, delayMs), token);
205
+ throw moved(DELAYED_ERROR_NAME);
206
+ }
207
+
208
+ /** A row on a lane's job, tracked so the slice's end can wait for it */
209
+ function laneLog(lane, row) {
210
+ const pending = log(lane.job, row);
211
+ lane.writes.add(pending);
212
+ pending.finally(() => lane.writes.delete(pending));
213
+ }
214
+
215
+ /**
216
+ * A lane's userland lines, into its own job's log — never allowed to fail
217
+ * it. A line with a group is a lane a migration job drives inline: its job
218
+ * is on the other queue, whatever its id.
219
+ */
220
+ function onUserland(event) {
221
+ if (event?.kind !== 'background' || event.jobId === undefined) return;
222
+ if (event.groupId !== undefined) return;
223
+ const lane = lanes.get(event.jobId);
224
+ if (lane === undefined) return;
225
+ if (lane.rows < settings.userlandLogRows) {
226
+ lane.rows += 1;
227
+ laneLog(lane, userlandRow(event));
228
+ } else {
229
+ lane.dropped += 1;
230
+ }
231
+ }
232
+ if (typeof kit.on === 'function') kit.on('migration:log', onUserland);
233
+
234
+ /**
235
+ * Run one slice as `job`'s: the slice gets `{ id }` for its lines and
236
+ * events, and its userland lines reach the job's log until the slice ends —
237
+ * drained before the job moves on, so none lands after it.
238
+ */
239
+ async function asLane(job, fn) {
240
+ const ref = jobRefOf(job);
241
+ if (ref === undefined) {
242
+ kit.logger.debug(
243
+ `Lane job ${job?.id} has no id a slice can carry — its lines name no job`,
244
+ {},
245
+ );
246
+ return fn(undefined);
247
+ }
248
+ const lane = { job, writes: new Set(), rows: 0, dropped: 0 };
249
+ lanes.set(ref.id, lane);
250
+ try {
251
+ return await fn(ref);
252
+ } finally {
253
+ if (lanes.get(ref.id) === lane) lanes.delete(ref.id);
254
+ if (lane.dropped > 0) {
255
+ laneLog(lane, userlandOverflowRow(lane.dropped, settings.userlandLogRows));
256
+ }
257
+ await Promise.allSettled([...lane.writes]);
258
+ }
259
+ }
260
+
261
+ /** Heal from MongoDB: a coordinator for every background migration with work to do */
262
+ async function heal(reason) {
263
+ try {
264
+ return await enqueueBackground(queue, kit, {
265
+ stallMs: settings.stallMs,
266
+ ...(jobOptions !== undefined ? { jobOptions } : {}),
267
+ });
268
+ } catch (error) {
269
+ kit.logger.warn(`⚠ Background heal (${reason}) failed: ${errorText(error)}`, {
270
+ error: errorText(error),
271
+ });
272
+ return { jobs: [] };
273
+ }
274
+ }
275
+
276
+ /** Whether this coordinator can spawn its lanes as children and wait for them */
277
+ function childrenFor(ctx) {
278
+ return (
279
+ settings.children !== false &&
280
+ typeof parentQueueOf(ctx.job) === 'string' &&
281
+ typeof ctx.job.moveToWaitingChildren === 'function' &&
282
+ typeof ctx.token === 'string'
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Where a coordinator job lives, for its lanes' `parent` — the job's own
288
+ * queue, which is the one a lane must wake, whatever `queue` this
289
+ * processor was handed to add the lanes to.
290
+ */
291
+ function parentQueueOf(job) {
292
+ return typeof job.queueQualifiedName === 'string'
293
+ ? job.queueQualifiedName
294
+ : queue.qualifiedName;
295
+ }
296
+
297
+ async function runCoordinator(ctx, data) {
298
+ const { job } = ctx;
299
+ const name = data.migration;
300
+ const base = { kind: JOB_NAMES.BACKGROUND, migration: name };
301
+ // The round is the kit's to hand out (under the coordinator lock): a new
302
+ // chain asks without one, and keeps the one it gets in its data across
303
+ // its moves.
304
+ let round = data.round;
305
+ let spawn = data.spawn ?? 0;
306
+ const children = childrenFor(ctx);
307
+ const keep = () => ({ ...job.data, ...(round !== undefined ? { round } : {}), spawn });
308
+ const withRound = (result) => (round !== undefined ? { ...result, round } : result);
309
+
310
+ for (let step = 0; ; step++) {
311
+ if (shutdownController.signal.aborted) return later(ctx, 0, withRound(base), keep());
312
+ const answer = await kit.coordinateBackground(name, {
313
+ signal: ctx.abort,
314
+ driver: { kind: 'bullmq', ref: String(job.id), ...(round !== undefined ? { round } : {}) },
315
+ });
316
+ if (answer.round !== undefined) round = answer.round;
317
+ await progress(job, { status: answer.next, ...(round !== undefined ? { round } : {}) });
318
+ if (answer.next === 'done') {
319
+ await log(job, `✔ ${name}: ${answer.status}`);
320
+ // Its dependents may just have been unblocked.
321
+ if (answer.status === 'completed') await heal('completed');
322
+ return withRound({ ...base, status: answer.status });
323
+ }
324
+ if (answer.next === 'superseded') {
325
+ await log(job, `↷ ${name}: superseded by a newer coordinator`);
326
+ kit.logger.info(`↷ Background coordinator of ${name} superseded by a newer one`, {
327
+ background: name,
328
+ job: String(job.id),
329
+ });
330
+ return withRound({ ...base, status: 'superseded' });
331
+ }
332
+ if (answer.next !== 'process' || answer.lanes === 0) {
333
+ return later(
334
+ ctx,
335
+ answer.retryAfterMs ?? settings.pollIntervalMs,
336
+ withRound({ ...base, status: answer.next }),
337
+ keep(),
338
+ );
339
+ }
340
+ if (!children) {
341
+ // No parents in this BullMQ (or turned off): lanes deduplicated per
342
+ // slot, and the coordinator looks again after a while.
343
+ await addLanes(ctx, { name, answer, round, spawn });
344
+ return later(
345
+ ctx,
346
+ settings.pollIntervalMs,
347
+ withRound({ ...base, status: 'process' }),
348
+ keep(),
349
+ );
350
+ }
351
+ if (step >= MAX_INLINE_STEPS) return later(ctx, 0, withRound(base), keep());
352
+ spawn += 1;
353
+ // Before the lanes: their ids carry the spawn, and a new one must never
354
+ // repeat one a finished lane already has.
355
+ await job.updateData(keep());
356
+ await addLanes(ctx, {
357
+ name,
358
+ answer,
359
+ round,
360
+ spawn,
361
+ parent: { id: String(job.id), queue: parentQueueOf(job) },
362
+ });
363
+ if (await job.moveToWaitingChildren(ctx.token)) throw moved(WAITING_CHILDREN_ERROR_NAME);
364
+ // Every lane finished before this job could wait for them: look again now.
365
+ }
366
+ }
367
+
368
+ async function addLanes(ctx, { name, answer, round, spawn, parent }) {
369
+ const specs = [];
370
+ for (let lane = 0; lane < answer.lanes; lane++) {
371
+ specs.push(
372
+ buildLaneJob({
373
+ migration: name,
374
+ registration: answer.registration,
375
+ generation: answer.generation,
376
+ round,
377
+ spawn,
378
+ lane,
379
+ ...(parent !== undefined ? { parent } : {}),
380
+ jobOptions,
381
+ }),
382
+ );
383
+ }
384
+ await queue.addBulk(specs);
385
+ await log(
386
+ ctx.job,
387
+ `⇉ ${name}: ${specs.length} lane(s) for generation ${answer.generation} (round ${round})`,
388
+ );
389
+ }
390
+
391
+ async function runLane(ctx, data) {
392
+ const { job } = ctx;
393
+ const name = data.migration;
394
+ const base = { kind: JOB_NAMES.BACKGROUND_LANE, migration: name };
395
+ if (shutdownController.signal.aborted) return later(ctx, 0, { ...base, outcome: 'stopped' });
396
+ let slice;
397
+ try {
398
+ slice = await asLane(job, (ref) =>
399
+ kit.runBackgroundSlice(name, {
400
+ signal: ctx.abort,
401
+ ...(settings.sliceMs !== undefined ? { sliceMs: settings.sliceMs } : {}),
402
+ ...(ref ? { job: ref } : {}),
403
+ }),
404
+ );
405
+ } catch (error) {
406
+ if (shutdownController.signal.aborted) {
407
+ return later(ctx, 0, { ...base, outcome: 'stopped' });
408
+ }
409
+ // The failure is already counted on its partition, in MongoDB — which
410
+ // fails the partition after `maxSliceFailures`; this lane only backs off.
411
+ const retry = data.retry + 1;
412
+ const message = errorText(error);
413
+ if (retry > settings.maxLaneRetries) {
414
+ kit.logger.warn(`⚠ Background lane of ${name} gave up: ${message}`, {
415
+ background: name,
416
+ error: message,
417
+ });
418
+ await log(job, `✖ gave up after ${data.retry} retries: ${message}`);
419
+ return {
420
+ ...base,
421
+ outcome: 'gave-up',
422
+ ...(error instanceof MigronautError ? { code: error.code } : {}),
423
+ };
424
+ }
425
+ await log(job, `⚠ slice failed (${message}) — retry ${retry}`);
426
+ kit.logger.warn(
427
+ `⚠ Background lane of ${name}: a slice failed (${message}) — retry ${retry}`,
428
+ {
429
+ background: name,
430
+ job: String(job.id),
431
+ retry,
432
+ error: message,
433
+ },
434
+ );
435
+ return later(
436
+ ctx,
437
+ Math.min(MAX_LANE_BACKOFF_MS, 1000 * 2 ** (retry - 1)),
438
+ { ...base, outcome: 'retry' },
439
+ { ...job.data, retry },
440
+ );
441
+ }
442
+ const reset = data.retry > 0 ? { ...job.data, retry: 0 } : undefined;
443
+ await progress(job, { outcome: slice.outcome, counters: slice.counters ?? {} });
444
+ switch (slice.outcome) {
445
+ case 'yielded':
446
+ case 'stopped':
447
+ case 'lost':
448
+ // Work is left — continue as the same job, behind whatever waits.
449
+ return later(ctx, 0, { ...base, outcome: slice.outcome }, reset);
450
+ case 'busy':
451
+ return later(
452
+ ctx,
453
+ slice.retryAfterMs ?? settings.pollIntervalMs,
454
+ { ...base, outcome: 'busy' },
455
+ reset,
456
+ );
457
+ default:
458
+ return { ...base, outcome: slice.outcome, counters: slice.counters };
459
+ }
460
+ }
461
+
462
+ async function runVerify() {
463
+ const result = await kit.verifyBackground();
464
+ const healed = await heal('verify');
465
+ return {
466
+ kind: JOB_NAMES.BACKGROUND_VERIFY,
467
+ checked: result.checked,
468
+ skipped: result.skipped,
469
+ drift: result.drift,
470
+ enqueued: healed.jobs.length,
471
+ };
472
+ }
473
+
474
+ async function handle(job, token, signal) {
475
+ const signals = [shutdownController.signal];
476
+ if (signal) signals.push(signal);
477
+ const ctx = { job, token, abort: AbortSignal.any(signals) };
478
+ const data = parseBackgroundJobData(job);
479
+ // A job fetched while this process shuts down goes back for another worker.
480
+ if (shutdownController.signal.aborted) {
481
+ return later(ctx, 0, { kind: data.kind, outcome: 'stopped' });
482
+ }
483
+ await kit.connect();
484
+ if (data.kind === JOB_NAMES.BACKGROUND) return runCoordinator(ctx, data);
485
+ if (data.kind === JOB_NAMES.BACKGROUND_LANE) return runLane(ctx, data);
486
+ return runVerify();
487
+ }
488
+
489
+ // Three declared parameters, on purpose — see the factory's doc comment.
490
+ async function processor(job, token, signal) {
491
+ const run = handle(job, token, signal);
492
+ inFlight.add(run);
493
+ try {
494
+ return await run;
495
+ } catch (error) {
496
+ if (error?.name !== DELAYED_ERROR_NAME && error?.name !== WAITING_CHILDREN_ERROR_NAME) {
497
+ // A coordinator has a few attempts; a failure no retry can fix
498
+ // (an invalid payload) is told apart by name, as BullMQ checks it.
499
+ if (!isRetryableError(error) && (job?.opts?.attempts ?? 1) > 1) {
500
+ error.name = UNRECOVERABLE_ERROR_NAME;
501
+ }
502
+ prepareErrorForQueue(error);
503
+ }
504
+ throw error;
505
+ } finally {
506
+ inFlight.delete(run);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Stop: a lane stops at its next batch boundary, checkpoints, releases its
512
+ * lease and goes back to the queue (moved to delayed, for the next worker);
513
+ * a coordinator that is deciding bows out and comes back. Irreversible.
514
+ */
515
+ processor.shutdown = (reason = 'Background worker shutting down') => {
516
+ if (!shutdownController.signal.aborted) {
517
+ shutdownController.abort(new RunAbortedError(reason, { reason }));
518
+ }
519
+ };
520
+
521
+ /** Shut down, let the jobs in flight settle, disconnect a kit the processor created */
522
+ processor.close = async () => {
523
+ processor.shutdown();
524
+ await Promise.allSettled([...inFlight]);
525
+ if (typeof kit.off === 'function') kit.off('migration:log', onUserland);
526
+ if (ownsKit) await kit.disconnect();
527
+ };
528
+
529
+ /** Heal from MongoDB now — what a worker does when it starts */
530
+ processor.heal = () => heal('boot');
531
+
532
+ Object.defineProperty(processor, 'kit', { value: kit, enumerable: true });
533
+ return processor;
534
+ }
535
+
536
+ module.exports = {
537
+ BACKGROUND_PROCESSOR_DEFAULTS: DEFAULTS,
538
+ MAX_SLICE_MS,
539
+ createBackgroundProcessor,
540
+ resolveBackgroundProcessorOptions,
541
+ };
@@ -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
  };