@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,951 @@
1
+ const os = require('node:os');
2
+ const { ConfigInvalidError, LockLostError } = require('../errors/index.js');
3
+ const { randomId } = require('../utils/id.js');
4
+ const { MAX_BAD_IDS } = require('./background-spec.js');
5
+ const { backgroundCollectionNames } = require('./config.js');
6
+ const { READ_OPTIONS } = require('./server-info.js');
7
+
8
+ /**
9
+ * Where background migrations keep their state, in MongoDB — the truth every
10
+ * runtime (a BullMQ worker, the CLI, an in-process runner) reads and writes;
11
+ * Redis and processes are only executors.
12
+ *
13
+ * - One **state document** per background migration (`_id` = its file name):
14
+ * status, phase, the current plan, totals, controls, history.
15
+ * - One **partition** document per range of the current plan: its cursor,
16
+ * counters and — while a lane works it — its **lease**. The lease *is* the
17
+ * lane's slot: a unique partial index on `{ background, lease.slot }` makes
18
+ * "at most `maxParallel` partitions at once" an invariant of the schema,
19
+ * across every process, with no lock of its own to heartbeat.
20
+ *
21
+ * Every lease write is fenced by the lease's token, and every staleness
22
+ * decision is made in server time (`$$NOW`). Mechanism only: no logger, no
23
+ * decisions — background.js and the engine decide.
24
+ */
25
+
26
+ /** The state document's schema — a worker refuses a newer one (an old release mid-deploy) */
27
+ const STATE_SCHEMA = 2;
28
+
29
+ /** The last document errors kept, per partition and in the state */
30
+ const MAX_DOC_ERRORS = 20;
31
+
32
+ /** History entries kept on a state document */
33
+ const MAX_HISTORY = 50;
34
+
35
+ /** Statuses of a partition still to be worked */
36
+ const OPEN_PARTITION = ['pending', 'running'];
37
+
38
+ const DUPLICATE_KEY = 11000;
39
+
40
+ /** A value as itself inside an update pipeline — a string with a leading `$` is not a field path */
41
+ const literal = (value) => ({ $literal: value });
42
+
43
+ /** `field + n` inside a pipeline, a missing field counting as 0 */
44
+ const plus = (field, n) => ({ $add: [{ $ifNull: [`$${field}`, 0] }, n] });
45
+
46
+ /** An array field with `items` appended, keeping the last `max` */
47
+ const appendSliced = (field, items, max) => ({
48
+ $slice: [{ $concatArrays: [{ $ifNull: [`$${field}`, []] }, literal(items)] }, -max],
49
+ });
50
+
51
+ /** What the indexes are: the state's listing order, the claims, and the lease slots */
52
+ const INDEXES = [
53
+ { on: 'state', key: { status: 1, registeredAt: 1 }, options: { name: 'status_registeredAt' } },
54
+ {
55
+ on: 'partitions',
56
+ key: { background: 1, generation: 1, plan: 1, status: 1, seq: 1 },
57
+ options: { name: 'claim' },
58
+ },
59
+ {
60
+ on: 'partitions',
61
+ key: { background: 1, 'lease.slot': 1 },
62
+ options: {
63
+ name: 'lease_slot',
64
+ unique: true,
65
+ partialFilterExpression: { 'lease.slot': { $exists: true } },
66
+ },
67
+ },
68
+ {
69
+ on: 'partitions',
70
+ key: { background: 1, 'lease.groupSlot': 1 },
71
+ options: {
72
+ name: 'lease_group_slot',
73
+ unique: true,
74
+ partialFilterExpression: { 'lease.groupSlot': { $exists: true } },
75
+ },
76
+ },
77
+ ];
78
+
79
+ const NAMESPACE_NOT_FOUND = 26;
80
+
81
+ class BackgroundStore {
82
+ #db;
83
+ #names;
84
+ #indexed;
85
+ #asserted;
86
+
87
+ /**
88
+ * `collection` is the `backgroundCollection` setting: the state lives
89
+ * there, the partitions in `<collection>_partitions`.
90
+ */
91
+ constructor(db, collection) {
92
+ this.#db = db;
93
+ this.#names = backgroundCollectionNames(collection);
94
+ }
95
+
96
+ get names() {
97
+ return this.#names;
98
+ }
99
+
100
+ get #states() {
101
+ return this.#db.collection(this.#names.state);
102
+ }
103
+
104
+ get #partitions() {
105
+ return this.#db.collection(this.#names.partitions);
106
+ }
107
+
108
+ /**
109
+ * The indexes, created on first use — never at connect: a database that
110
+ * never registers a background migration gets no collections at all. Must
111
+ * run outside a transaction (a registration inside `useTransaction` calls
112
+ * it first).
113
+ */
114
+ async ensureIndexes() {
115
+ this.#indexed ??= Promise.all(
116
+ INDEXES.map(({ on, key, options }) => this.#collection(on).createIndex(key, options)),
117
+ ).catch((error) => {
118
+ this.#indexed = undefined;
119
+ throw error;
120
+ });
121
+ await this.#indexed;
122
+ }
123
+
124
+ /**
125
+ * For `ensureIndexes: false` (a user who cannot create indexes): the lease
126
+ * slots are capped by unique indexes, so a lane refuses to claim without
127
+ * them rather than run past `maxParallel`. Checked once per store.
128
+ *
129
+ * @throws {ConfigInvalidError} naming each missing index and its key
130
+ */
131
+ async assertIndexes() {
132
+ this.#asserted ??= (async () => {
133
+ const missing = [];
134
+ const present = new Map();
135
+ for (const on of ['state', 'partitions']) {
136
+ let names = new Set();
137
+ try {
138
+ const indexes = await this.#collection(on).listIndexes().toArray();
139
+ names = new Set(indexes.map((index) => index.name));
140
+ } catch (error) {
141
+ if (error?.code !== NAMESPACE_NOT_FOUND) throw error;
142
+ }
143
+ present.set(on, names);
144
+ }
145
+ for (const { on, key, options } of INDEXES) {
146
+ if (!present.get(on).has(options.name)) {
147
+ missing.push(`${this.#names[on]}: ${JSON.stringify(key)} (${options.name})`);
148
+ }
149
+ }
150
+ if (missing.length > 0) {
151
+ throw new ConfigInvalidError(
152
+ 'Background migrations need their indexes, and ensureIndexes is false — create ' +
153
+ `them (or let migronaut): ${missing.join('; ')}`,
154
+ { missing },
155
+ );
156
+ }
157
+ })().catch((error) => {
158
+ this.#asserted = undefined;
159
+ throw error;
160
+ });
161
+ await this.#asserted;
162
+ }
163
+
164
+ #collection(on) {
165
+ return on === 'state' ? this.#states : this.#partitions;
166
+ }
167
+
168
+ // ─── State ──────────────────────────────────────────────────────────────────
169
+
170
+ /** A state document, read from the primary — or `null`; `projection` to read less of it */
171
+ get(name, { session, projection } = {}) {
172
+ return this.#states.findOne(
173
+ { _id: name },
174
+ { ...READ_OPTIONS, ...(session ? { session } : {}), ...(projection ? { projection } : {}) },
175
+ );
176
+ }
177
+
178
+ /** The state documents of `names`, by name — one read */
179
+ async getMany(names) {
180
+ const byName = new Map();
181
+ if (names.length === 0) return byName;
182
+ const docs = await this.#states.find({ _id: { $in: names } }, READ_OPTIONS).toArray();
183
+ for (const doc of docs) byName.set(doc._id, doc);
184
+ return byName;
185
+ }
186
+
187
+ /**
188
+ * Every state document matching `filter`, oldest registration first —
189
+ * `projection` to leave out what a frequent reader does not need (the
190
+ * history, the document errors).
191
+ */
192
+ list(filter = {}, { projection } = {}) {
193
+ return this.#states
194
+ .find(filter, { ...READ_OPTIONS, ...(projection ? { projection } : {}) })
195
+ .sort({ registeredAt: 1, _id: 1 })
196
+ .toArray();
197
+ }
198
+
199
+ /**
200
+ * Register (or register again) a background migration: a fresh state
201
+ * document under a new `registration` id, and no partitions — a plan of
202
+ * the old registration must not be resumed against the new one. A new
203
+ * registration keeps the old one's history, and a summary of it as
204
+ * `previous` (a revert replacing a forward run must not erase its trail).
205
+ */
206
+ async register(name, fields, { session } = {}) {
207
+ const options = session ? { session } : {};
208
+ const now = new Date();
209
+ const old = await this.#states.findOne(
210
+ { _id: name },
211
+ {
212
+ projection: {
213
+ registration: 1,
214
+ status: 1,
215
+ direction: 1,
216
+ pass: 1,
217
+ totals: 1,
218
+ registeredAt: 1,
219
+ completedAt: 1,
220
+ history: 1,
221
+ },
222
+ ...READ_OPTIONS,
223
+ ...options,
224
+ },
225
+ );
226
+ const history = Array.isArray(old?.history) ? old.history.slice(-(MAX_HISTORY - 1)) : [];
227
+ history.push({
228
+ at: now,
229
+ action: 'register',
230
+ ...(old ? { from: old.status } : {}),
231
+ to: fields.status,
232
+ });
233
+ const doc = {
234
+ _id: name,
235
+ schema: STATE_SCHEMA,
236
+ registration: randomId(),
237
+ phase: 'partition',
238
+ pass: 0,
239
+ generation: 0,
240
+ rolledGeneration: 0,
241
+ totals: {},
242
+ badIds: [],
243
+ docErrors: [],
244
+ failures: 0,
245
+ reopened: 0,
246
+ history,
247
+ ...(old
248
+ ? {
249
+ previous: {
250
+ registration: old.registration,
251
+ status: old.status,
252
+ direction: old.direction,
253
+ pass: old.pass,
254
+ totals: old.totals ?? {},
255
+ registeredAt: old.registeredAt,
256
+ ...(old.completedAt ? { completedAt: old.completedAt } : {}),
257
+ },
258
+ }
259
+ : {}),
260
+ registeredAt: now,
261
+ updatedAt: now,
262
+ ...fields,
263
+ };
264
+ // The old partitions go first: a crash in between leaves a state with no
265
+ // partitions (the coordinator plans again), never partitions of an old
266
+ // registration that a new one's generation numbers would count as its own.
267
+ await this.#partitions.deleteMany({ background: name }, options);
268
+ await this.#states.replaceOne({ _id: name }, doc, { upsert: true, ...options });
269
+ return doc;
270
+ }
271
+
272
+ /**
273
+ * Compare-and-set a state document: `filter` on top of the `_id`, `update`
274
+ * an operator update or a pipeline. Returns the document after, or `null`
275
+ * when the filter no longer matched.
276
+ */
277
+ cas(name, filter, update, { session } = {}) {
278
+ return this.#states.findOneAndUpdate({ _id: name, ...filter }, update, {
279
+ returnDocument: 'after',
280
+ ...READ_OPTIONS,
281
+ ...(session ? { session } : {}),
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Move a state document from one of `from` to `to`, appending a history
287
+ * entry and setting `fields`. Returns the document after, or `null` when
288
+ * its status was no longer one of `from` (someone else moved it).
289
+ */
290
+ move(name, { from, to, action, fields = {}, filter = {}, by, reason }) {
291
+ const entry = {
292
+ at: '$$NOW',
293
+ action: literal(action),
294
+ from: '$status',
295
+ to: literal(to),
296
+ ...(by !== undefined ? { by: literal(by) } : {}),
297
+ ...(reason !== undefined ? { reason: literal(reason) } : {}),
298
+ };
299
+ // One stage: `$status` in the entry is the status before it.
300
+ const set = {
301
+ status: literal(to),
302
+ updatedAt: '$$NOW',
303
+ history: {
304
+ $slice: [{ $concatArrays: [{ $ifNull: ['$history', []] }, [entry]] }, -MAX_HISTORY],
305
+ },
306
+ };
307
+ for (const [key, value] of Object.entries(fields)) set[key] = literal(value);
308
+ return this.cas(name, { status: { $in: from }, ...filter }, [{ $set: set }]);
309
+ }
310
+
311
+ /**
312
+ * Append a history entry with no status change — a control that is not a
313
+ * transition (an unlock). `null` when it is not registered.
314
+ */
315
+ note(name, { action, by, reason, ...details }) {
316
+ const entry = {
317
+ at: '$$NOW',
318
+ action: literal(action),
319
+ ...(by !== undefined ? { by: literal(by) } : {}),
320
+ ...(reason !== undefined ? { reason: literal(reason) } : {}),
321
+ };
322
+ for (const [key, value] of Object.entries(details)) entry[key] = literal(value);
323
+ return this.cas(name, {}, [
324
+ {
325
+ $set: {
326
+ updatedAt: '$$NOW',
327
+ history: {
328
+ $slice: [{ $concatArrays: [{ $ifNull: ['$history', []] }, [entry]] }, -MAX_HISTORY],
329
+ },
330
+ },
331
+ },
332
+ ]);
333
+ }
334
+
335
+ /** Set fields of a state document (no status change) */
336
+ async set(name, fields, { filter = {}, session } = {}) {
337
+ const result = await this.#states.updateOne(
338
+ { _id: name, ...filter },
339
+ { $set: { ...fields, updatedAt: new Date() } },
340
+ session ? { session } : {},
341
+ );
342
+ return result.matchedCount === 1;
343
+ }
344
+
345
+ /**
346
+ * Delete a state document and its partitions — with `filter`, only while
347
+ * the state still matches it (`{ generation: 0 }`: no plan was ever
348
+ * committed, so no lane can have written). Returns whether it was deleted.
349
+ */
350
+ async remove(name, { session, filter } = {}) {
351
+ const options = session ? { session } : {};
352
+ const result = await this.#states.deleteOne({ _id: name, ...filter }, options);
353
+ if (filter !== undefined && result.deletedCount !== 1) return false;
354
+ await this.#partitions.deleteMany({ background: name }, options);
355
+ return result.deletedCount === 1;
356
+ }
357
+
358
+ // ─── Plans ──────────────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Commit a new plan: its partitions inserted under a fresh plan token, then
362
+ * the state moved to them in one compare-and-set on the generation and the
363
+ * plan it was planned against. A coordinator that lost that race (another
364
+ * one committed first) leaves only partitions nobody can claim — their
365
+ * plan is not the state's — and removes them. Returns the state after, or
366
+ * `null` when the race was lost.
367
+ */
368
+ async commitPlan(
369
+ name,
370
+ { generation, previousToken, plan, partitions, fields = {}, filter = {} },
371
+ ) {
372
+ const token = randomId();
373
+ const nextGeneration = generation + 1;
374
+ if (partitions.length > 0) {
375
+ const docs = partitions.map((partition, seq) => ({
376
+ background: name,
377
+ generation: nextGeneration,
378
+ plan: token,
379
+ seq,
380
+ scope: partition.scope,
381
+ status: 'pending',
382
+ ...(partition.group !== undefined ? { group: partition.group } : {}),
383
+ estimate: partition.estimate ?? 0,
384
+ cursor: {},
385
+ counters: {},
386
+ claims: 0,
387
+ reclaims: 0,
388
+ failures: 0,
389
+ createdAt: new Date(),
390
+ }));
391
+ await this.#partitions.insertMany(docs, { ordered: false });
392
+ }
393
+ const state = await this.cas(
394
+ name,
395
+ { ...filter, generation, 'plan.token': previousToken ?? { $exists: false } },
396
+ {
397
+ $set: {
398
+ generation: nextGeneration,
399
+ plan: {
400
+ ...plan,
401
+ token,
402
+ generation: nextGeneration,
403
+ partitions: partitions.length,
404
+ at: new Date(),
405
+ },
406
+ phase: 'process',
407
+ updatedAt: new Date(),
408
+ ...fields,
409
+ },
410
+ },
411
+ );
412
+ if (state === null) {
413
+ await this.#partitions.deleteMany({ background: name, plan: token });
414
+ }
415
+ return state;
416
+ }
417
+
418
+ /** Mark the open partitions of a plan superseded — a replan is coming */
419
+ async supersede(name, { generation, plan }) {
420
+ const result = await this.#partitions.updateMany(
421
+ { background: name, generation, plan, status: { $in: OPEN_PARTITION } },
422
+ { $set: { status: 'superseded', updatedAt: new Date() } },
423
+ );
424
+ return result.modifiedCount;
425
+ }
426
+
427
+ /** How the partitions of a plan stand: a count per status, and how many are leased */
428
+ async partitionCounts(name, { generation, plan }) {
429
+ const rows = await this.#partitions
430
+ .aggregate(
431
+ [
432
+ { $match: { background: name, generation, plan } },
433
+ {
434
+ $group: {
435
+ _id: '$status',
436
+ count: { $sum: 1 },
437
+ leased: { $sum: { $cond: [{ $gt: ['$lease', null] }, 1, 0] } },
438
+ },
439
+ },
440
+ ],
441
+ READ_OPTIONS,
442
+ )
443
+ .toArray();
444
+ const counts = {
445
+ total: 0,
446
+ pending: 0,
447
+ running: 0,
448
+ done: 0,
449
+ failed: 0,
450
+ cancelled: 0,
451
+ superseded: 0,
452
+ leased: 0,
453
+ };
454
+ for (const row of rows) {
455
+ counts[row._id] = row.count;
456
+ counts.total += row.count;
457
+ counts.leased += row.leased;
458
+ }
459
+ return counts;
460
+ }
461
+
462
+ /** The partitions of one background migration — newest generation first, by seq */
463
+ partitions(name, filter = {}) {
464
+ return this.#partitions
465
+ .find({ background: name, ...filter }, READ_OPTIONS)
466
+ .sort({ generation: -1, seq: 1 })
467
+ .toArray();
468
+ }
469
+
470
+ /**
471
+ * The totals of a generation's partitions, for the state's roll-up: the
472
+ * counters summed, the bad ids united, the newest document errors.
473
+ */
474
+ async generationTotals(name, generation) {
475
+ const [row] = await this.#partitions
476
+ .aggregate(
477
+ [
478
+ { $match: { background: name, generation } },
479
+ {
480
+ $group: {
481
+ _id: null,
482
+ scanned: { $sum: { $ifNull: ['$counters.scanned', 0] } },
483
+ migrated: { $sum: { $ifNull: ['$counters.migrated', 0] } },
484
+ skipped: { $sum: { $ifNull: ['$counters.skipped', 0] } },
485
+ conflicts: { $sum: { $ifNull: ['$counters.conflicts', 0] } },
486
+ failed: { $sum: { $ifNull: ['$counters.failed', 0] } },
487
+ retried: { $sum: { $ifNull: ['$counters.retried', 0] } },
488
+ batches: { $sum: { $ifNull: ['$counters.batches', 0] } },
489
+ slices: { $sum: { $ifNull: ['$counters.slices', 0] } },
490
+ txnRetries: { $sum: { $ifNull: ['$counters.txnRetries', 0] } },
491
+ reclaims: { $sum: { $ifNull: ['$reclaims', 0] } },
492
+ badIds: { $push: { $ifNull: ['$badIds', []] } },
493
+ docErrors: { $push: { $ifNull: ['$lastDocErrors', []] } },
494
+ failedPartitions: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
495
+ // The error of a failed partition before any other, then the
496
+ // newest: a document compares field by field.
497
+ lastError: {
498
+ $max: {
499
+ failed: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] },
500
+ has: { $cond: [{ $gt: ['$lastError', null] }, 1, 0] },
501
+ at: '$updatedAt',
502
+ error: '$lastError',
503
+ },
504
+ },
505
+ },
506
+ },
507
+ ],
508
+ READ_OPTIONS,
509
+ )
510
+ .toArray();
511
+ if (row === undefined) return null;
512
+ const { _id: _ignored, badIds, docErrors, lastError, ...rest } = row;
513
+ const totals = { ...rest, ...(lastError?.error ? { lastError: lastError.error } : {}) };
514
+ const ids = [];
515
+ for (const list of badIds) ids.push(...list);
516
+ const errors = [];
517
+ for (const list of docErrors) errors.push(...list);
518
+ return { totals, badIds: ids, docErrors: errors.slice(-MAX_DOC_ERRORS) };
519
+ }
520
+
521
+ /**
522
+ * The distinct documents that failed — the state's (earlier generations,
523
+ * already excluded from later passes) plus this generation's partitions'.
524
+ */
525
+ async countBadIds(name, generation) {
526
+ const [state, rows] = await Promise.all([
527
+ this.#states.findOne({ _id: name }, { projection: { badIds: 1 }, ...READ_OPTIONS }),
528
+ this.#partitions
529
+ .aggregate(
530
+ [
531
+ { $match: { background: name, generation, 'badIds.0': { $exists: true } } },
532
+ { $unwind: '$badIds' },
533
+ { $group: { _id: '$badIds' } },
534
+ { $count: 'n' },
535
+ ],
536
+ READ_OPTIONS,
537
+ )
538
+ .toArray(),
539
+ ]);
540
+ return (state?.badIds?.length ?? 0) + (rows[0]?.n ?? 0);
541
+ }
542
+
543
+ /**
544
+ * What a background migration has done so far: documents rewritten
545
+ * (`migrated`) and batches — or steps — checkpointed (`batches`), the
546
+ * rolled-up totals plus the partitions not rolled up yet. Zeros for one
547
+ * that is not registered. A step migration says how many documents it
548
+ * rewrote only when it wants to; every checkpointed step counts as a batch.
549
+ */
550
+ async progress(name) {
551
+ const state = await this.get(name);
552
+ if (state === null) return { migrated: 0, batches: 0 };
553
+ const [row] = await this.#partitions
554
+ .aggregate(
555
+ [
556
+ { $match: { background: name, generation: { $gt: state.rolledGeneration ?? 0 } } },
557
+ {
558
+ $group: {
559
+ _id: null,
560
+ migrated: { $sum: { $ifNull: ['$counters.migrated', 0] } },
561
+ batches: { $sum: { $ifNull: ['$counters.batches', 0] } },
562
+ },
563
+ },
564
+ ],
565
+ READ_OPTIONS,
566
+ )
567
+ .toArray();
568
+ return {
569
+ migrated: (state.totals?.migrated ?? 0) + (row?.migrated ?? 0),
570
+ batches: (state.totals?.batches ?? 0) + (row?.batches ?? 0),
571
+ };
572
+ }
573
+
574
+ /** Partitions of a generation outside its current plan — what a lost commit race left */
575
+ countForeignPlans(name, { generation, plan }) {
576
+ return this.#partitions.countDocuments({ background: name, generation, plan: { $ne: plan } });
577
+ }
578
+
579
+ /** Delete the partitions of generations up to `generation` — `keep` spares one */
580
+ async dropGenerations(name, generation, { keep } = {}) {
581
+ const filter = { background: name, generation: { $lte: generation } };
582
+ if (keep !== undefined) filter.generation.$ne = keep;
583
+ const result = await this.#partitions.deleteMany(filter);
584
+ return result.deletedCount;
585
+ }
586
+
587
+ /** Delete the partitions of every plan but `plan` — what a lost commit race left behind */
588
+ async dropForeignPlans(name, plan) {
589
+ const result = await this.#partitions.deleteMany({
590
+ background: name,
591
+ plan: { $ne: plan },
592
+ status: { $in: [...OPEN_PARTITION, 'superseded'] },
593
+ });
594
+ return result.deletedCount;
595
+ }
596
+
597
+ /** Set the status of a plan's open partitions — `cancelled`, or `pending` again for a retry */
598
+ async setOpenPartitions(name, { generation, plan, from = OPEN_PARTITION, status }) {
599
+ const result = await this.#partitions.updateMany(
600
+ { background: name, generation, plan, status: { $in: from } },
601
+ { $set: { status, updatedAt: new Date(), ...(status === 'pending' ? { failures: 0 } : {}) } },
602
+ );
603
+ return result.modifiedCount;
604
+ }
605
+
606
+ // ─── Leases ─────────────────────────────────────────────────────────────────
607
+
608
+ /**
609
+ * Free the leases nobody renewed within their own TTL — in server time.
610
+ * Returns how many were reclaimed.
611
+ */
612
+ async reap(name) {
613
+ const result = await this.#partitions.updateMany(
614
+ {
615
+ // Every lease has a slot: on that, the partial slot index serves the read.
616
+ background: name,
617
+ 'lease.slot': { $exists: true },
618
+ $expr: { $lt: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
619
+ },
620
+ [{ $set: { reclaims: plus('reclaims', 1) } }, { $unset: 'lease' }],
621
+ );
622
+ return result.modifiedCount;
623
+ }
624
+
625
+ /** How many leases are held — and how many were renewed within their TTL */
626
+ async leases(name) {
627
+ const [row] = await this.#partitions
628
+ .aggregate(
629
+ [
630
+ { $match: { background: name, 'lease.slot': { $exists: true } } },
631
+ {
632
+ $group: {
633
+ _id: null,
634
+ held: { $sum: 1 },
635
+ live: {
636
+ $sum: {
637
+ $cond: [
638
+ { $gte: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
639
+ 1,
640
+ 0,
641
+ ],
642
+ },
643
+ },
644
+ },
645
+ },
646
+ ],
647
+ READ_OPTIONS,
648
+ )
649
+ .toArray();
650
+ return { held: row?.held ?? 0, live: row?.live ?? 0 };
651
+ }
652
+
653
+ /** Leases renewed within their TTL, per background migration of `names` — one read */
654
+ async liveLeasesOf(names) {
655
+ const live = new Map();
656
+ if (names.length === 0) return live;
657
+ const rows = await this.#partitions
658
+ .aggregate(
659
+ [
660
+ { $match: { background: { $in: names }, 'lease.slot': { $exists: true } } },
661
+ {
662
+ $match: {
663
+ $expr: { $gte: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
664
+ },
665
+ },
666
+ { $group: { _id: '$background', live: { $sum: 1 } } },
667
+ ],
668
+ READ_OPTIONS,
669
+ )
670
+ .toArray();
671
+ for (const row of rows) live.set(row._id, row.live);
672
+ return live;
673
+ }
674
+
675
+ /** Drop every lease of a background migration — their holders are fenced off at once */
676
+ async unlockAll(name) {
677
+ const result = await this.#partitions.updateMany(
678
+ { background: name, 'lease.slot': { $exists: true } },
679
+ { $unset: { lease: '' } },
680
+ );
681
+ return result.modifiedCount;
682
+ }
683
+
684
+ /**
685
+ * Claim a partition of the current plan, and a slot with it, in one atomic
686
+ * write. Expired leases are reaped first; then each free slot below
687
+ * `maxParallel` is tried (from a random offset, so concurrent claimers
688
+ * spread out): the unique index refuses a slot another claim took a moment
689
+ * earlier, and the next one is tried. Partitions already started come
690
+ * first, then the largest.
691
+ *
692
+ * Returns `{ partition, lease }`, `{ busy: true, retryAfterMs }` when every
693
+ * slot is taken, or `{ exhausted: true }` when no partition is left to claim.
694
+ */
695
+ async claim(name, { generation, plan, maxParallel, ttlMs, owner, shardConcurrency, onReaped }) {
696
+ const reaped = await this.reap(name);
697
+ if (reaped > 0) onReaped?.(reaped);
698
+ const occupied = new Set();
699
+ const perGroup = new Map();
700
+ const leased = await this.#partitions
701
+ .find(
702
+ { background: name, 'lease.slot': { $exists: true } },
703
+ { projection: { 'lease.slot': 1, group: 1 }, ...READ_OPTIONS },
704
+ )
705
+ .toArray();
706
+ for (const doc of leased) {
707
+ occupied.add(doc.lease.slot);
708
+ if (doc.group !== undefined) perGroup.set(doc.group, (perGroup.get(doc.group) ?? 0) + 1);
709
+ }
710
+ // Sharded: a group (shard) with every one of its slots held is not claimed from.
711
+ const fullGroups = [];
712
+ if (shardConcurrency !== undefined) {
713
+ for (const [group, held] of perGroup) if (held >= shardConcurrency) fullGroups.push(group);
714
+ }
715
+ const free = [];
716
+ for (let slot = 0; slot < maxParallel; slot++) if (!occupied.has(slot)) free.push(slot);
717
+ if (free.length === 0) return { busy: true, retryAfterMs: Math.max(1, Math.floor(ttlMs / 2)) };
718
+
719
+ const offset = Math.floor(Math.random() * free.length);
720
+ for (let i = 0; i < free.length; i++) {
721
+ const slot = free[(offset + i) % free.length];
722
+ const token = randomId();
723
+ const lease = {
724
+ slot,
725
+ token: literal(token),
726
+ owner: literal(owner ?? token),
727
+ host: literal(os.hostname()),
728
+ pid: literal(process.pid),
729
+ ttlMs: literal(ttlMs),
730
+ renewedAt: '$$NOW',
731
+ claimedAt: '$$NOW',
732
+ };
733
+ let partition;
734
+ try {
735
+ partition = await this.#claimOne(name, {
736
+ generation,
737
+ plan,
738
+ lease,
739
+ shardConcurrency,
740
+ fullGroups,
741
+ });
742
+ } catch (error) {
743
+ if (error?.code === DUPLICATE_KEY) continue;
744
+ throw error;
745
+ }
746
+ if (partition === null) return { exhausted: true };
747
+ return { partition, lease: this.lease(partition._id, token, ttlMs) };
748
+ }
749
+ // Every free slot was taken by a concurrent claim.
750
+ return { busy: true, retryAfterMs: Math.max(1, Math.floor(ttlMs / 4)) };
751
+ }
752
+
753
+ /** One claim attempt for one slot (and, sharded, one per-group slot) */
754
+ async #claimOne(name, { generation, plan, lease, shardConcurrency, fullGroups = [] }) {
755
+ const filter = {
756
+ background: name,
757
+ generation,
758
+ plan,
759
+ status: { $in: OPEN_PARTITION },
760
+ lease: { $exists: false },
761
+ ...(fullGroups.length > 0 ? { group: { $nin: fullGroups } } : {}),
762
+ };
763
+ const set = {
764
+ lease,
765
+ status: 'running',
766
+ claims: plus('claims', 1),
767
+ startedAt: { $ifNull: ['$startedAt', '$$NOW'] },
768
+ updatedAt: '$$NOW',
769
+ };
770
+ const sort = { status: -1, seq: 1 };
771
+ if (shardConcurrency === undefined) {
772
+ return this.#partitions.findOneAndUpdate(filter, [{ $set: set }], {
773
+ sort,
774
+ returnDocument: 'after',
775
+ ...READ_OPTIONS,
776
+ });
777
+ }
778
+ // Per group (shard): a second unique slot, `<group>#<k>`. The candidate is
779
+ // picked first, so a group found full is known by name — and left out of
780
+ // the next pick, rather than ending the claim as busy. Each round either
781
+ // claims, finds the candidate taken, or rules out one more group.
782
+ const full = new Set(fullGroups);
783
+ let ungroupedFull = false;
784
+ for (;;) {
785
+ const pick = { ...filter };
786
+ if (full.size > 0) pick.group = { $nin: [...full] };
787
+ // Partitions without a group share one set of group slots (`#k`).
788
+ if (ungroupedFull) pick.group = { ...pick.group, $exists: true };
789
+ const candidate = await this.#partitions.findOne(pick, {
790
+ sort,
791
+ projection: { group: 1 },
792
+ ...READ_OPTIONS,
793
+ });
794
+ if (candidate === null) return null;
795
+ let taken = false;
796
+ for (let k = 0; k < shardConcurrency && !taken; k++) {
797
+ try {
798
+ const claimed = await this.#partitions.findOneAndUpdate(
799
+ { ...filter, _id: candidate._id },
800
+ [{ $set: { ...set, lease: { ...lease, groupSlot: `${candidate.group ?? ''}#${k}` } } }],
801
+ { returnDocument: 'after', ...READ_OPTIONS },
802
+ );
803
+ if (claimed !== null) return claimed;
804
+ // Claimed by someone else between the pick and the write: pick again.
805
+ taken = true;
806
+ } catch (error) {
807
+ if (error?.code !== DUPLICATE_KEY || /lease_slot/.test(String(error?.message))) {
808
+ throw error;
809
+ }
810
+ }
811
+ }
812
+ if (taken) continue;
813
+ if (candidate.group === undefined) ungroupedFull = true;
814
+ else full.add(candidate.group);
815
+ }
816
+ }
817
+
818
+ /**
819
+ * A lease as a lock: what `runWithLock` heartbeats. `acquire` is a no-op —
820
+ * the claim took it — and `renew` skips the write when a checkpoint
821
+ * renewed it less than a quarter TTL ago (a checkpoint renews too; inside a
822
+ * transaction an extra write to the partition would be a write conflict).
823
+ */
824
+ lease(partitionId, token, ttlMs) {
825
+ const partitions = this.#partitions;
826
+ let touchedAt = Date.now();
827
+ return {
828
+ label: 'partition lease',
829
+ token,
830
+ ttlMs,
831
+ partitionId,
832
+ touch() {
833
+ touchedAt = Date.now();
834
+ },
835
+ async acquire() {},
836
+ async renew() {
837
+ if (Date.now() - touchedAt < ttlMs / 4) return true;
838
+ const result = await partitions.updateOne({ _id: partitionId, 'lease.token': token }, [
839
+ { $set: { 'lease.renewedAt': '$$NOW' } },
840
+ ]);
841
+ if (result.matchedCount === 1) touchedAt = Date.now();
842
+ return result.matchedCount === 1;
843
+ },
844
+ async release() {
845
+ await partitions.updateOne(
846
+ { _id: partitionId, 'lease.token': token },
847
+ { $unset: { lease: '' }, $set: { updatedAt: new Date() } },
848
+ );
849
+ },
850
+ };
851
+ }
852
+
853
+ /**
854
+ * The fenced checkpoint after a batch: only the lease holder's write lands
855
+ * (`lease.token`), on a partition still running. It moves the cursor, adds
856
+ * the batch's counters, keeps new bad ids and document errors, renews the
857
+ * lease — and, with `done`, closes the partition and frees its slot.
858
+ *
859
+ * @throws {LockLostError} (`lease: true`) when the lease is no longer held
860
+ */
861
+ async checkpoint(lease, update, { session } = {}) {
862
+ const { cursor, counters = {}, badIds = [], docErrors = [], done = false, throttle } = update;
863
+ // A checkpoint is progress: `maxSliceFailures` counts failed slices in a
864
+ // row, so a transient error now and then never adds up to a failure.
865
+ const set = { 'lease.renewedAt': '$$NOW', updatedAt: '$$NOW', failures: literal(0) };
866
+ if (cursor !== undefined) set.cursor = literal(cursor);
867
+ for (const [key, value] of Object.entries(counters)) {
868
+ if (value) set[`counters.${key}`] = plus(`counters.${key}`, value);
869
+ }
870
+ if (badIds.length > 0) {
871
+ set.badIds = {
872
+ $slice: [{ $setUnion: [{ $ifNull: ['$badIds', []] }, literal(badIds)] }, MAX_BAD_IDS + 1],
873
+ };
874
+ }
875
+ if (docErrors.length > 0) {
876
+ set.lastDocErrors = appendSliced('lastDocErrors', docErrors, MAX_DOC_ERRORS);
877
+ }
878
+ if (throttle !== undefined) set.throttle = literal(throttle);
879
+ const pipeline = [{ $set: set }];
880
+ if (done) {
881
+ pipeline.push({ $set: { status: 'done', doneAt: '$$NOW' } }, { $unset: 'lease' });
882
+ }
883
+ const result = await this.#partitions.updateOne(
884
+ { _id: lease.partitionId, 'lease.token': lease.token, status: 'running' },
885
+ pipeline,
886
+ session ? { session } : {},
887
+ );
888
+ if (result.matchedCount !== 1) {
889
+ throw new LockLostError('Lost the partition lease mid-slice', {
890
+ lease: true,
891
+ partition: String(lease.partitionId),
892
+ });
893
+ }
894
+ // Inside a transaction the renewal lands only with the commit: the caller
895
+ // touches the lease then — a failed commit must not skip the heartbeat.
896
+ if (session === undefined) lease.touch();
897
+ }
898
+
899
+ /**
900
+ * A failed slice: counted on the partition — which fails for good once it
901
+ * reaches `maxSliceFailures` — and the lease released. Returns whether the
902
+ * partition failed.
903
+ */
904
+ async failSlice(lease, { error, maxSliceFailures }) {
905
+ // The lease is usually released by the time the failure is counted
906
+ // (runWithLock's finally); a lease of another lane, though, means this
907
+ // one was fenced off — its failure is not the partition's.
908
+ const doc = await this.#partitions.findOneAndUpdate(
909
+ {
910
+ _id: lease.partitionId,
911
+ status: { $in: OPEN_PARTITION },
912
+ $or: [{ 'lease.token': lease.token }, { lease: { $exists: false } }],
913
+ },
914
+ [
915
+ { $set: { failures: plus('failures', 1), lastError: literal(error), updatedAt: '$$NOW' } },
916
+ {
917
+ $set: {
918
+ status: { $cond: [{ $gte: ['$failures', maxSliceFailures] }, 'failed', '$status'] },
919
+ },
920
+ },
921
+ { $unset: 'lease' },
922
+ ],
923
+ { returnDocument: 'after', ...READ_OPTIONS },
924
+ );
925
+ return doc?.status === 'failed';
926
+ }
927
+
928
+ /** Fail a partition at once (a document error beyond the budget) and free its slot */
929
+ async failPartition(lease, { error }) {
930
+ await this.#partitions.updateOne(
931
+ {
932
+ _id: lease.partitionId,
933
+ // Not one another lane has finished since (done, its lease gone).
934
+ status: { $in: OPEN_PARTITION },
935
+ $or: [{ 'lease.token': lease.token }, { lease: { $exists: false } }],
936
+ },
937
+ [
938
+ { $set: { status: 'failed', lastError: literal(error), updatedAt: '$$NOW' } },
939
+ { $unset: 'lease' },
940
+ ],
941
+ );
942
+ }
943
+ }
944
+
945
+ module.exports = {
946
+ BackgroundStore,
947
+ MAX_BAD_IDS,
948
+ MAX_DOC_ERRORS,
949
+ OPEN_PARTITION,
950
+ STATE_SCHEMA,
951
+ };