@alexify/migronaut 2.0.0 → 2.2.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 (54) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/README.md +235 -6
  3. package/bullmq.d.ts +860 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +888 -19
  6. package/migronaut.schema.json +238 -1
  7. package/package.json +21 -5
  8. package/src/bullmq/index.js +55 -0
  9. package/src/bullmq/jobs.js +454 -0
  10. package/src/bullmq/processor.js +632 -0
  11. package/src/bullmq/producer.js +427 -0
  12. package/src/bullmq/service.js +653 -0
  13. package/src/bullmq/wait.js +124 -0
  14. package/src/cli/args.js +12 -2
  15. package/src/cli/commands/converge.js +188 -0
  16. package/src/cli/commands/down.js +2 -0
  17. package/src/cli/commands/lock.js +2 -1
  18. package/src/cli/commands/redo.js +8 -1
  19. package/src/cli/commands/up.js +14 -1
  20. package/src/cli/exit-codes.js +9 -2
  21. package/src/cli/index.js +2 -0
  22. package/src/cli/shared.js +14 -4
  23. package/src/cli/table.js +164 -0
  24. package/src/core/audit.js +88 -3
  25. package/src/core/changelog.js +71 -6
  26. package/src/core/collections.js +396 -0
  27. package/src/core/config.js +130 -25
  28. package/src/core/converge-log.js +47 -0
  29. package/src/core/converge-plan.js +686 -0
  30. package/src/core/converge-search-run.js +440 -0
  31. package/src/core/converge-search.js +404 -0
  32. package/src/core/converge.js +1024 -0
  33. package/src/core/index-spec.js +507 -0
  34. package/src/core/lock-wait.js +260 -0
  35. package/src/core/lock.js +95 -28
  36. package/src/core/migrator.js +600 -287
  37. package/src/core/options.js +266 -0
  38. package/src/core/run-recorder.js +157 -0
  39. package/src/core/run.js +58 -90
  40. package/src/core/search-index-spec.js +758 -0
  41. package/src/core/sequence.js +134 -0
  42. package/src/core/server-info.js +63 -0
  43. package/src/errors/index.js +60 -0
  44. package/src/index.js +8 -0
  45. package/src/utils/actor.js +48 -0
  46. package/src/utils/canonical.js +212 -0
  47. package/src/utils/collection-name.js +21 -0
  48. package/src/utils/error.js +18 -1
  49. package/src/utils/id.js +77 -0
  50. package/src/utils/loader.js +39 -21
  51. package/src/utils/migration-name.js +32 -0
  52. package/src/utils/redact.js +21 -1
  53. package/src/utils/telemetry.js +410 -0
  54. package/src/utils/template.js +43 -2
@@ -0,0 +1,1024 @@
1
+ const { ConvergeFailedError, MigronautError } = require('../errors/index.js');
2
+ const { pickActor } = require('../utils/actor.js');
3
+ const { mapLimit } = require('../utils/concurrency.js');
4
+ const { errorText } = require('../utils/error.js');
5
+ const {
6
+ CHANGE_ACTIONS,
7
+ SEARCH_UNAVAILABLE_REASON,
8
+ TARGET_LABELS,
9
+ isDestructive,
10
+ planCollection,
11
+ } = require('./converge-plan.js');
12
+ const {
13
+ SEARCH_STEPS,
14
+ SEARCH_UNAVAILABLE_HINT,
15
+ isSearchUnavailable,
16
+ runSearchStep,
17
+ searchHint,
18
+ } = require('./converge-search.js');
19
+ const {
20
+ SEARCH_SETTLE_DELAYS_MS,
21
+ notReadyEntry,
22
+ pause,
23
+ readSearch,
24
+ readSearchIndexes,
25
+ refreshBuilds,
26
+ reportNotReady,
27
+ searchSummary,
28
+ waitPhase,
29
+ warnIgnored,
30
+ } = require('./converge-search-run.js');
31
+ const { inPlaceCapabilities } = require('./index-spec.js');
32
+ const {
33
+ INDEX_NOT_FOUND,
34
+ NAMESPACE_NOT_FOUND,
35
+ READ_CONCURRENCY,
36
+ READ_OPTIONS,
37
+ readServer,
38
+ } = require('./server-info.js');
39
+
40
+ /**
41
+ * Converge: bring the declared collections' indexes, search indexes and
42
+ * validators to their declared state. Stateless — every run reads
43
+ * `listCollections`, `listIndexes` (and `$listSearchIndexes` where search
44
+ * indexes are declared), plans against what it finds (converge-plan.js), and carries
45
+ * the plan out one operation at a time. The declaration is the only source of
46
+ * truth, and the database is checked against it afresh each time: the history
47
+ * a run appends (converge-log.js) is for people, and nothing reads it back to
48
+ * decide what to do.
49
+ *
50
+ * Pure orchestration over capabilities the MigratorKit injects (`deps`):
51
+ * `{db, logger, fields, emit, assertNotAborted}`, and optionally `audit` +
52
+ * `record` (the history entry), `shardKeyOf` (behind a mongos), `releaseLock`
53
+ * (`() => Promise<boolean>`: give the run's lock up before waiting for search
54
+ * index builds), `recordSearchWait` (`(waitedMs, outcome)`: the wait's
55
+ * metric point), and `sleep` (`(ms, signal) => Promise`, cut short by an
56
+ * abort) and `now` (`() => ms`) — the pause between search index reads and
57
+ * the clock that times a wait for them, for tests.
58
+ */
59
+
60
+ const NAMESPACE_EXISTS = 48;
61
+
62
+ /** What usually fixes the server error behind a failed step */
63
+ const HINTS = {
64
+ 11000:
65
+ 'existing documents hold duplicate values for this unique index — deduplicate them in a ' +
66
+ 'migration first',
67
+ 13: 'not authorized — collMod and creating a collection with a validator need the dbAdmin role',
68
+ 85:
69
+ 'an equivalent index already exists under another name — declare it under that name, or ' +
70
+ 'converge with prune to replace it',
71
+ 86: 'an index with this name already exists with a different key or options',
72
+ 359:
73
+ 'existing documents hold duplicate values for this key — deduplicate them in a migration ' +
74
+ 'first (the index was left as it was)',
75
+ };
76
+
77
+ /** Shard key of each named collection, behind a mongos — when `config.collections` may be read */
78
+ async function readShardKeys(deps, names) {
79
+ const keys = new Map();
80
+ if (typeof deps.shardKeyOf !== 'function') return keys;
81
+ for (const name of names) {
82
+ try {
83
+ const key = await deps.shardKeyOf(name);
84
+ if (key) keys.set(name, key);
85
+ } catch {
86
+ // Not readable (privileges): a refused drop is caught when it happens.
87
+ }
88
+ }
89
+ return keys;
90
+ }
91
+
92
+ /** The server's refusal to drop the index that backs a shard key */
93
+ function isShardKeyRefusal(error) {
94
+ return /shard key/i.test(errorText(error));
95
+ }
96
+
97
+ const VERBS = { create: 'create', modify: 'modify', recreate: 'rebuild', drop: 'drop' };
98
+ const LABELS = {
99
+ create: '✔ Created ',
100
+ modify: '✔ Modified',
101
+ recreate: '✔ Rebuilt ',
102
+ drop: '✔ Dropped ',
103
+ };
104
+
105
+ /** A row's target as a log line names it, and what it is on */
106
+ function whatOf(action, collection) {
107
+ const target = TARGET_LABELS[action.target] ?? action.target;
108
+ const named = action.target === 'index' || action.target === 'searchIndex';
109
+ return `${target} ${named ? `${action.name} on ${collection}` : collection}`;
110
+ }
111
+
112
+ /**
113
+ * Every named collection's live state, in order: one `listCollections` for
114
+ * all of them, then their `listIndexes` a few at a time — not two sequential
115
+ * round trips per collection, which for a few hundred declared collections
116
+ * is the better part of a minute before the first decision.
117
+ */
118
+ async function readLiveStates(db, names) {
119
+ const infos = await db
120
+ .listCollections({ name: { $in: names } }, { nameOnly: false, ...READ_OPTIONS })
121
+ .toArray();
122
+ const byName = new Map();
123
+ for (const info of infos) byName.set(info.name, info);
124
+ return mapLimit(names, READ_CONCURRENCY, (name) => liveStateOf(db, name, byName.get(name)));
125
+ }
126
+
127
+ /**
128
+ * One collection's live state: `{ exists, type?, options?, indexes }`. A view
129
+ * or a time-series collection is reported by type (the planner refuses it)
130
+ * without listing indexes.
131
+ */
132
+ async function readLiveState(db, name) {
133
+ const [state] = await readLiveStates(db, [name]);
134
+ return state;
135
+ }
136
+
137
+ async function liveStateOf(db, name, info) {
138
+ if (!info) return { exists: false, indexes: [] };
139
+ const options = info.options ?? {};
140
+ if (info.type !== undefined && info.type !== 'collection') {
141
+ return { exists: true, type: info.type, options, indexes: [] };
142
+ }
143
+ try {
144
+ const indexes = await db.collection(name).listIndexes(READ_OPTIONS).toArray();
145
+ return { exists: true, type: 'collection', options, indexes };
146
+ } catch (error) {
147
+ // Dropped between the two reads — plan it as missing, like the first read would have.
148
+ if (error?.code === NAMESPACE_NOT_FOUND) return { exists: false, indexes: [] };
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ /** Copy-on-write, like the kit's #attachResults: the error may live on a shared abort signal */
154
+ function attachConverge(error, result) {
155
+ if (error instanceof MigronautError) {
156
+ error.context = { ...error.context, converge: result };
157
+ }
158
+ return error;
159
+ }
160
+
161
+ function describe(action) {
162
+ if (action.target === 'index') return `index "${action.name}"`;
163
+ if (action.target === 'searchIndex') return `search index "${action.name}"`;
164
+ return action.target === 'validator' ? 'the validator' : 'the collection';
165
+ }
166
+
167
+ /** What a failed step was doing — one row, or the indexes one command built together */
168
+ function describeAll(actions) {
169
+ if (actions.length === 1) return describe(actions[0]);
170
+ const names = actions.map((action) => `"${action.name}"`).join(', ');
171
+ return actions[0].target === 'searchIndex' ? `search indexes ${names}` : `indexes ${names}`;
172
+ }
173
+
174
+ /**
175
+ * Errors that leave it unknown what the server did: the connection broke, or
176
+ * a client-side deadline ran out, while the server may well still be building.
177
+ * After one of these, nothing is "put back" — a restore would race a build the
178
+ * server is still running; the next converge reads what actually happened.
179
+ */
180
+ const CONNECTION_ERROR_NAMES = new Set([
181
+ 'MongoNetworkError',
182
+ 'MongoNetworkTimeoutError',
183
+ 'MongoServerSelectionError',
184
+ 'MongoOperationTimeoutError',
185
+ 'MongoTopologyClosedError',
186
+ ]);
187
+ const CONNECTION_ERROR_CODES = new Set([50, 89, 91, 189, 10107, 11600, 11602, 13435]);
188
+
189
+ function isConnectionTrouble(error) {
190
+ return CONNECTION_ERROR_NAMES.has(error?.name) || CONNECTION_ERROR_CODES.has(error?.code);
191
+ }
192
+
193
+ /** What a failed rebuild left behind, when it could not put the dropped index back */
194
+ function lostIndexes(extra) {
195
+ if (extra.restored !== false) return '';
196
+ if (extra.uncertain) {
197
+ return (
198
+ ` — the connection failed mid-build, so ${extra.dropped.map((name) => `"${name}"`).join(', ')} ` +
199
+ 'was not put back: the server may still be building; converge --dry-run shows what it finished'
200
+ );
201
+ }
202
+ return (
203
+ ` — the dropped index(es) ${extra.dropped.map((name) => `"${name}"`).join(', ')} could ` +
204
+ `not be put back (${extra.restoreError}); the collection is without them until fixed`
205
+ );
206
+ }
207
+
208
+ function wrapFailure(error, collection, actions, result, extra = {}) {
209
+ const action = actions[0];
210
+ if (error instanceof MigronautError) return attachConverge(error, result);
211
+ const mongoCode = typeof error?.code === 'number' ? error.code : undefined;
212
+ const hint =
213
+ action.target === 'searchIndex'
214
+ ? searchHint(error)
215
+ : mongoCode !== undefined
216
+ ? HINTS[mongoCode]
217
+ : undefined;
218
+ const cause = errorText(error);
219
+ return new ConvergeFailedError(
220
+ `Could not ${VERBS[action.action] ?? action.action} ${describeAll(actions)} on ${collection}: ` +
221
+ `${cause}${hint ? ` — ${hint}` : ''}${lostIndexes(extra)}`,
222
+ {
223
+ phase: 'apply',
224
+ collection,
225
+ target: action.target,
226
+ name: action.name,
227
+ action: action.action,
228
+ cause,
229
+ ...(mongoCode !== undefined ? { mongoCode } : {}),
230
+ ...(hint ? { hint } : {}),
231
+ ...extra,
232
+ converge: result,
233
+ },
234
+ { cause: error },
235
+ );
236
+ }
237
+
238
+ async function dropIndex(db, collection, name) {
239
+ try {
240
+ await db.collection(collection).dropIndex(name);
241
+ } catch (error) {
242
+ // Already gone is the state this step wanted.
243
+ if (error?.code !== INDEX_NOT_FOUND) throw error;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Drop, then create — the only way to change most index options. A failed
249
+ * create (a unique index over duplicate data, typically) would otherwise
250
+ * leave the collection without the index it had a moment ago, so every
251
+ * dropped index whose name is still free is put back, best-effort, and the
252
+ * failure says whether that worked.
253
+ *
254
+ * Returns undefined on success, or `{ error, action, extra }` for the create
255
+ * that failed — a drop that fails has nothing to restore and simply throws.
256
+ */
257
+ async function runRebuild(db, collection, step, settle) {
258
+ const dropped = [];
259
+ for (const drop of step.drops) {
260
+ await dropIndex(db, collection, drop.name);
261
+ dropped.push(drop);
262
+ }
263
+ const created = new Set();
264
+ for (const { spec, action } of step.creates) {
265
+ const startedAt = Date.now();
266
+ try {
267
+ await db.collection(collection).createIndexes([spec]);
268
+ } catch (error) {
269
+ const droppedNames = dropped.map((drop) => drop.name);
270
+ if (isConnectionTrouble(error)) {
271
+ return {
272
+ error,
273
+ actions: [action],
274
+ extra: { restored: false, uncertain: true, dropped: droppedNames },
275
+ };
276
+ }
277
+ const restoreErrors = [];
278
+ for (const drop of dropped) {
279
+ if (created.has(drop.name)) continue;
280
+ try {
281
+ await db.collection(collection).createIndexes([drop.restore]);
282
+ } catch (restoreError) {
283
+ restoreErrors.push(`${drop.name}: ${errorText(restoreError)}`);
284
+ }
285
+ }
286
+ return {
287
+ error,
288
+ actions: [action],
289
+ extra: {
290
+ restored: restoreErrors.length === 0,
291
+ dropped: droppedNames,
292
+ ...(restoreErrors.length > 0 ? { restoreError: restoreErrors.join('; ') } : {}),
293
+ },
294
+ };
295
+ }
296
+ created.add(spec.name);
297
+ settle(action, Date.now() - startedAt);
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Make an index unique in place (MongoDB 7.0+): `prepareUnique` first — from
304
+ * then on no new duplicate can be written — then `unique`, which checks the
305
+ * existing documents. If they hold duplicates, `prepareUnique` is taken back
306
+ * off, so the index is exactly as it was before. Never a window without the
307
+ * index, and no rebuild of a large collection.
308
+ */
309
+ async function convertToUnique(db, collection, step) {
310
+ await db.command({ collMod: collection, index: { name: step.name, prepareUnique: true } });
311
+ try {
312
+ await db.command({ collMod: collection, index: { name: step.name, unique: true } });
313
+ } catch (error) {
314
+ await db
315
+ .command({ collMod: collection, index: { name: step.name, prepareUnique: false } })
316
+ .catch(() => undefined);
317
+ throw error;
318
+ }
319
+ if (Object.keys(step.rest).length > 0) {
320
+ await db.command({ collMod: collection, index: { name: step.name, ...step.rest } });
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Carry out one planned step; returns `{ error, actions, extra }` on failure,
326
+ * `{ kept }` / `{ skipped }` for a step the server turned down in a way the
327
+ * run can go on from, else undefined. `search` is the run's search state.
328
+ */
329
+ async function runStep(db, collection, step, settle, search) {
330
+ try {
331
+ if (step.op === 'rebuild') return await runRebuild(db, collection, step, settle);
332
+ const startedAt = Date.now();
333
+ if (SEARCH_STEPS.has(step.op)) {
334
+ try {
335
+ await runSearchStep(db, collection, step);
336
+ } catch (error) {
337
+ // The probe said Search was there, the server says otherwise: with
338
+ // onSearchUnavailable 'skip' that is what the configuration expects.
339
+ if (search?.onUnavailable !== 'skip' || !isSearchUnavailable(error)) throw error;
340
+ for (const action of step.actions) {
341
+ action.action = 'skip';
342
+ action.reason = SEARCH_UNAVAILABLE_REASON;
343
+ }
344
+ return { skipped: true };
345
+ }
346
+ } else if (step.op === 'createCollection') {
347
+ try {
348
+ await db.createCollection(collection, step.options);
349
+ } catch (error) {
350
+ // Created by someone else since it was read — set the validator on it instead.
351
+ if (error?.code !== NAMESPACE_EXISTS) throw error;
352
+ if (step.options.validator) await db.command({ collMod: collection, ...step.options });
353
+ }
354
+ } else if (step.op === 'collMod') {
355
+ await db.command({ collMod: collection, ...step.command });
356
+ } else if (step.op === 'convertUnique') {
357
+ await convertToUnique(db, collection, step);
358
+ } else if (step.op === 'createIndexes') {
359
+ // One command, one pass over the collection for all of them — and all
360
+ // or nothing: a build that fails leaves none of the batch behind.
361
+ await db.collection(collection).createIndexes(step.specs);
362
+ } else {
363
+ try {
364
+ await dropIndex(db, collection, step.name);
365
+ } catch (error) {
366
+ // A sharded collection's shard-key index cannot be dropped, and its
367
+ // shard key could not be read up front: keep it, say so, go on.
368
+ if (!isShardKeyRefusal(error)) throw error;
369
+ for (const action of step.actions) {
370
+ action.action = 'keep';
371
+ action.reason = 'backs the shard key';
372
+ }
373
+ return { kept: true };
374
+ }
375
+ }
376
+ const durationMs = Date.now() - startedAt;
377
+ for (const action of step.actions) settle(action, durationMs);
378
+ return undefined;
379
+ } catch (error) {
380
+ // One command for several indexes fails (or succeeds) for all of them.
381
+ const together = step.op === 'createIndexes' || step.op === 'createSearchIndexes';
382
+ return { error, actions: together ? step.actions : [step.actions[0]], extra: {} };
383
+ }
384
+ }
385
+
386
+ /** Mark every row that never ran, once a run stops early */
387
+ function settleRest(result) {
388
+ for (const collection of result.collections) {
389
+ for (const action of collection.actions) {
390
+ if (action.status !== 'planned') continue;
391
+ action.status = action.action === 'conflict' ? 'failed' : 'skipped';
392
+ }
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Everything the closing report needs, from one pass over every row: the
398
+ * counts behind `changed` and `inSync`, the collections touched, the
399
+ * undeclared indexes kept, a count per action kind, the rows the history
400
+ * keeps, and — when search indexes are declared — those not serving yet.
401
+ * With `settle`, a row never reached is settled first, as in settleRest.
402
+ */
403
+ function tally(result, search, settle) {
404
+ const totals = {
405
+ changes: 0,
406
+ applied: 0,
407
+ conflicts: 0,
408
+ touched: 0,
409
+ kept: { indexes: 0, searchIndexes: 0 },
410
+ counts: {},
411
+ history: [],
412
+ notReady: [],
413
+ };
414
+ const searching = search?.declared === true;
415
+ for (const collection of result.collections) {
416
+ let touched = false;
417
+ for (const action of collection.actions) {
418
+ if (settle && action.status === 'planned') {
419
+ action.status = action.action === 'conflict' ? 'failed' : 'skipped';
420
+ }
421
+ const kind = action.action;
422
+ totals.counts[kind] = (totals.counts[kind] ?? 0) + 1;
423
+ const change = CHANGE_ACTIONS.has(kind);
424
+ if (change) {
425
+ totals.changes += 1;
426
+ if (action.status === 'applied') totals.applied += 1;
427
+ if (result.dryRun || action.status === 'applied') touched = true;
428
+ } else if (kind === 'conflict') {
429
+ totals.conflicts += 1;
430
+ } else if (kind === 'keep' && action.reason === 'not declared') {
431
+ // Left in place because prune was off — not the ones that back a shard
432
+ // key (or are being deleted), for which "converge with prune" is wrong advice.
433
+ if (action.target === 'searchIndex') totals.kept.searchIndexes += 1;
434
+ else totals.kept.indexes += 1;
435
+ }
436
+ // The history keeps what changed, failed or refused the run.
437
+ if (change || kind === 'conflict' || action.status === 'failed') {
438
+ totals.history.push([collection.name, action]);
439
+ }
440
+ if (searching) {
441
+ const entry = notReadyEntry(collection.name, action);
442
+ if (entry) totals.notReady.push(entry);
443
+ }
444
+ }
445
+ if (touched) totals.touched += 1;
446
+ }
447
+ return totals;
448
+ }
449
+
450
+ /** The result's closing figures — `changed`, `inSync`, `search` — and the tally behind them */
451
+ function finalize(result, search, settle = false) {
452
+ const totals = tally(result, search, settle);
453
+ if (search?.declared) result.search = searchSummary(search, totals.notReady);
454
+ const { changes, applied, conflicts } = totals;
455
+ if (result.dryRun) {
456
+ result.changed = changes;
457
+ result.inSync = changes === 0 && conflicts === 0;
458
+ } else {
459
+ result.changed = applied;
460
+ result.inSync = conflicts === 0 && applied === changes && !(result.unstable?.length > 0);
461
+ }
462
+ return totals;
463
+ }
464
+
465
+ /** Steps that build an index — the ones that can take minutes or hours */
466
+ const BUILD_STEPS = new Set(['createIndexes', 'rebuild']);
467
+ const STARTING = {
468
+ create: 'Creating',
469
+ modify: 'Modifying',
470
+ recreate: 'Rebuilding',
471
+ drop: 'Dropping',
472
+ };
473
+
474
+ /**
475
+ * Say a step is starting, before it runs: the `converge:action` event with
476
+ * status `'started'` for every step, and a log line for an index build — on a
477
+ * large collection it is the only sign of which index the run is busy with.
478
+ */
479
+ function announce(deps, collection, step) {
480
+ for (const action of step.actions) {
481
+ deps.emit('converge:action', {
482
+ collection,
483
+ target: action.target,
484
+ name: action.name,
485
+ action: action.action,
486
+ status: 'started',
487
+ ...(action.reason !== undefined ? { reason: action.reason } : {}),
488
+ });
489
+ const line = `… ${STARTING[action.action] ?? action.action} ${whatOf(action, collection)}`;
490
+ const fields = deps.fields({
491
+ collection,
492
+ target: action.target,
493
+ name: action.name,
494
+ action: action.action,
495
+ status: 'started',
496
+ });
497
+ if (BUILD_STEPS.has(step.op)) deps.logger.info(line, fields);
498
+ else deps.logger.debug(line, fields);
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Append the run to the converge history — a run that changed something or
504
+ * failed; a converge that found everything in place is not news. Best-effort,
505
+ * like the changelog's failure trace: a history that cannot be written is
506
+ * warned about, never allowed to turn a converge that worked into a failure.
507
+ */
508
+ async function recordHistory(deps, options, result, { startedAt, error, history }) {
509
+ if (typeof deps.record !== 'function') return;
510
+ if (error === undefined && result.changed === 0) return;
511
+ const finishedAt = new Date();
512
+ try {
513
+ await deps.record({
514
+ ...deps.audit(),
515
+ trigger: options.trigger ?? 'converge',
516
+ startedAt: new Date(startedAt),
517
+ finishedAt,
518
+ durationMs: finishedAt.getTime() - startedAt,
519
+ success: error === undefined,
520
+ ...(error !== undefined ? { error: errorText(error) } : {}),
521
+ ...pickActor(options),
522
+ changed: result.changed,
523
+ actions: history.map(([collection, action]) => ({ collection, ...action })),
524
+ ...(result.unstable ? { unstable: result.unstable } : {}),
525
+ ...(result.search ? { search: result.search } : {}),
526
+ });
527
+ } catch (recordError) {
528
+ deps.logger.warn(
529
+ `⚠ Could not record the converge in its history: ${errorText(recordError)}`,
530
+ deps.fields({ error: errorText(recordError) }),
531
+ );
532
+ }
533
+ }
534
+
535
+ /**
536
+ * The read and plan phases: every declared collection's live state (one
537
+ * read for all of them), and its plan. Behind a mongos each live state also
538
+ * carries the collection's shard key, so the plan keeps the index behind it.
539
+ *
540
+ * Returns `{ planFor, live, plans }` — `planFor(definition, live)` plans one
541
+ * collection with the run's options, for the re-plans and the fixed-point
542
+ * check to come.
543
+ */
544
+ async function readAndPlan(deps, options) {
545
+ const { db } = deps;
546
+ const { definitions } = options;
547
+ const pruneFor = (definition) => definition.prune ?? options.prune ?? false;
548
+ const server = await readServer(db);
549
+ const capabilities = inPlaceCapabilities(server.version);
550
+ // The names to read, and whether any definition declares search indexes — one pass.
551
+ const names = new Array(definitions.length);
552
+ let declared = false;
553
+ for (const [position, definition] of definitions.entries()) {
554
+ names[position] = definition.name;
555
+ if (definition.searchIndexes !== undefined) declared = true;
556
+ }
557
+ const search = {
558
+ declared,
559
+ available: true,
560
+ onUnavailable: options.search?.onUnavailable ?? 'fail',
561
+ waitRequested: options.search?.wait === true && !options.dryRun,
562
+ };
563
+ // Reads `search` at call time: a skip at apply time turns Search off for the rest of the run.
564
+ const planFor = (definition, live) =>
565
+ planCollection(definition, live, {
566
+ prune: pruneFor(definition),
567
+ rebuildUnique: options.rebuildUnique === true,
568
+ capabilities,
569
+ search: { available: search.available, onUnavailable: search.onUnavailable },
570
+ });
571
+
572
+ const live = await readLiveStates(db, names);
573
+ if (server.mongos) {
574
+ const shardKeys = await readShardKeys(deps, names);
575
+ for (const [position, name] of names.entries()) {
576
+ if (shardKeys.has(name)) live[position].shardKey = shardKeys.get(name);
577
+ }
578
+ }
579
+ if (search.declared) await readSearch(deps, server, definitions, live, search);
580
+ const plans = new Array(definitions.length);
581
+ const ignored = [];
582
+ for (const [position, definition] of definitions.entries()) {
583
+ plans[position] = planFor(definition, live[position]);
584
+ reviewPlan(deps, definition, plans[position], pruneFor(definition), ignored);
585
+ }
586
+ warnIgnored(deps, ignored);
587
+ return { planFor, live, plans, search };
588
+ }
589
+
590
+ /**
591
+ * One pass over a fresh plan for what is worth saying before anything runs:
592
+ * the search index options the comparison left out (gathered into `ignored`,
593
+ * for one line about all of them), and every drop of `indexes: []` or
594
+ * `searchIndexes: []` with prune — "none here", every index but _id goes.
595
+ * Legitimate, and easy to write by accident: say it out loud.
596
+ */
597
+ function reviewPlan(deps, definition, plan, prune, ignored) {
598
+ const emptied =
599
+ prune && (definition.indexes?.length === 0 || definition.searchIndexes?.length === 0);
600
+ const indexDrops = [];
601
+ const searchDrops = [];
602
+ for (const action of plan.actions) {
603
+ if (action.ignored) ignored.push(`${plan.name}.${action.name} (${action.ignored.join(', ')})`);
604
+ if (!emptied || action.action !== 'drop') continue;
605
+ if (action.target === 'index') indexDrops.push(action.name);
606
+ else if (action.target === 'searchIndex') searchDrops.push(action.name);
607
+ }
608
+ if (!emptied) return;
609
+ for (const [key, names, what] of [
610
+ ['indexes', indexDrops, 'every index but _id'],
611
+ ['searchIndexes', searchDrops, 'every search index'],
612
+ ]) {
613
+ if (definition[key]?.length !== 0 || names.length === 0) continue;
614
+ deps.logger.warn(
615
+ `⚠ ${definition.name}: ${key}: [] with prune drops ${what} (${names.join(', ')})`,
616
+ deps.fields({ collection: definition.name, drops: names.length }),
617
+ );
618
+ }
619
+ }
620
+
621
+ /**
622
+ * One collection's live state read afresh — its search indexes too, when it
623
+ * declares any and the server has Search — for a re-plan (`phase: 'replan'`)
624
+ * or the verify phase (`'apply'`): what a failed read is reported as.
625
+ */
626
+ async function readFresh(run, position, phase) {
627
+ const { deps, definitions, live, search } = run;
628
+ const definition = definitions[position];
629
+ const fresh = await readLiveState(deps.db, definition.name);
630
+ if (live[position].shardKey) fresh.shardKey = live[position].shardKey;
631
+ if (
632
+ search.available &&
633
+ definition.searchIndexes !== undefined &&
634
+ fresh.exists &&
635
+ fresh.type === 'collection'
636
+ ) {
637
+ fresh.searchIndexes = await readSearchIndexes(deps, definition.name, phase);
638
+ }
639
+ return fresh;
640
+ }
641
+
642
+ /** A dry run's answer: the plan as the result, and one line about it */
643
+ function reportPlan(deps, result, search) {
644
+ const totals = finalize(result, search);
645
+ const total = result.collections.length;
646
+ const line =
647
+ `◎ Planned ${result.changed} change(s) in ${totals.touched} of ${total} ` + 'collection(s)';
648
+ const fields = deps.fields({ dryRun: true, changed: result.changed, collections: total });
649
+ // A probe that finds nothing to do (a scheduler tick, a CI gate) is not news.
650
+ if (result.inSync) deps.logger.debug(line, fields);
651
+ else deps.logger.info(line, fields);
652
+ return result;
653
+ }
654
+
655
+ /** The guard phase: a plan with any conflict refuses the whole run, before the first write */
656
+ function refuseConflicts(result) {
657
+ const conflicts = [];
658
+ let unavailable = false;
659
+ for (const collection of result.collections) {
660
+ for (const action of collection.actions) {
661
+ if (action.action !== 'conflict') continue;
662
+ if (action.reason === SEARCH_UNAVAILABLE_REASON) unavailable = true;
663
+ conflicts.push({
664
+ collection: collection.name,
665
+ target: action.target,
666
+ name: action.name,
667
+ reason: action.reason,
668
+ ...(action.liveName !== undefined ? { liveName: action.liveName } : {}),
669
+ });
670
+ }
671
+ }
672
+ if (conflicts.length === 0) return;
673
+ settleRest(result);
674
+ throw new ConvergeFailedError(
675
+ `Converge refused: ${conflicts.length} conflict(s) — ` +
676
+ conflicts
677
+ .map((conflict) =>
678
+ conflict.target === 'collection'
679
+ ? `${conflict.collection} ${conflict.reason}`
680
+ : `${conflict.collection} ${describe(conflict)}: ${conflict.reason}`,
681
+ )
682
+ .join('; ') +
683
+ (unavailable ? ` — ${SEARCH_UNAVAILABLE_HINT}` : ''),
684
+ {
685
+ phase: 'plan',
686
+ conflicts,
687
+ ...(unavailable ? { hint: SEARCH_UNAVAILABLE_HINT } : {}),
688
+ converge: result,
689
+ },
690
+ );
691
+ }
692
+
693
+ /**
694
+ * The collection at `position`, planned afresh — the guard again, right
695
+ * before its turn. It may only have become *less* to do: a conflict or a
696
+ * drop/rebuild that the plan the run started from did not have — an index
697
+ * someone created meanwhile — refuses the run here, before this collection is
698
+ * touched, rather than act on what nobody reviewed.
699
+ */
700
+ async function replan(run, position) {
701
+ const { definitions, plans, result, planFor } = run;
702
+ const definition = definitions[position];
703
+ const plan = planFor(definition, await readFresh(run, position, 'replan'));
704
+ const known = new Set();
705
+ for (const action of plans[position].actions) {
706
+ known.add(`${action.target}:${action.name}:${action.action}`);
707
+ }
708
+ const introduced = plan.actions.filter(
709
+ (action) =>
710
+ (action.action === 'conflict' || isDestructive(action)) &&
711
+ !known.has(`${action.target}:${action.name}:${action.action}`),
712
+ );
713
+ if (introduced.length === 0) return plan;
714
+ result.collections[position].actions = plan.actions;
715
+ settleRest(result);
716
+ throw new ConvergeFailedError(
717
+ `Converge stopped before ${definition.name}: it changed while the run was under way — ` +
718
+ introduced.map((action) => `${describe(action)} now ${action.action}`).join('; '),
719
+ {
720
+ phase: 'replan',
721
+ collection: definition.name,
722
+ introduced: introduced.map(({ target, name, action, reason }) => ({
723
+ target,
724
+ name,
725
+ action,
726
+ ...(reason !== undefined ? { reason } : {}),
727
+ })),
728
+ converge: result,
729
+ },
730
+ );
731
+ }
732
+
733
+ /** A declared index found under another name is kept as it is — say so */
734
+ function warnRenamed(deps, plan) {
735
+ for (const action of plan.actions) {
736
+ if (action.liveName !== undefined && action.action === 'unchanged') {
737
+ deps.logger.warn(
738
+ `⚠ ${plan.name}: index "${action.name}" exists as "${action.liveName}" — kept under ` +
739
+ 'its current name',
740
+ deps.fields({ collection: plan.name, index: action.name, liveName: action.liveName }),
741
+ );
742
+ }
743
+ }
744
+ }
745
+
746
+ /** Mark a row applied, and say so: the `converge:action` event and its log line */
747
+ function settler(deps, collection) {
748
+ return (action, durationMs) => {
749
+ action.status = 'applied';
750
+ action.durationMs = durationMs;
751
+ deps.emit('converge:action', {
752
+ collection,
753
+ target: action.target,
754
+ name: action.name,
755
+ action: action.action,
756
+ status: 'applied',
757
+ durationMs,
758
+ ...(action.reason !== undefined ? { reason: action.reason } : {}),
759
+ });
760
+ // A search index is only accepted here; the server builds it afterwards.
761
+ const building =
762
+ action.target === 'searchIndex' && action.action !== 'drop'
763
+ ? ' — building on the server'
764
+ : '';
765
+ deps.logger.info(
766
+ `${LABELS[action.action]} ${whatOf(action, collection)} [${durationMs}ms]${building}`,
767
+ deps.fields({
768
+ collection,
769
+ target: action.target,
770
+ name: action.name,
771
+ action: action.action,
772
+ durationMs,
773
+ }),
774
+ );
775
+ };
776
+ }
777
+
778
+ /** Stop here when the run was aborted: every row not reached is settled, the result attached */
779
+ function stopIfAborted(deps, signal, result) {
780
+ try {
781
+ deps.assertNotAborted(signal);
782
+ } catch (error) {
783
+ settleRest(result);
784
+ throw attachConverge(error, result);
785
+ }
786
+ }
787
+
788
+ /**
789
+ * The apply phase for one collection: its steps, in order. Returns the
790
+ * indexes the server would not let go of (a shard key's), which the verify
791
+ * phase must not report as unstable. A failed step stops the run.
792
+ */
793
+ async function applyCollection(deps, plan, result, signal, search) {
794
+ const kept = new Set();
795
+ const settle = settler(deps, plan.name);
796
+ for (const step of plan.steps) {
797
+ // Between operations is the only safe place to stop — and never inside
798
+ // a rebuild, which runs its drop and its create back to back.
799
+ stopIfAborted(deps, signal, result);
800
+ announce(deps, plan.name, step);
801
+ const failure = await runStep(deps.db, plan.name, step, settle, search);
802
+ if (failure?.skipped) {
803
+ // Search turned out to be missing after all: the rest of the run plans
804
+ // every search index as skipped instead of asking again.
805
+ search.available = false;
806
+ deps.logger.warn(
807
+ `⚠ ${plan.name}: Atlas Search refused ${describeAll(step.actions)} — skipped ` +
808
+ "(onSearchUnavailable: 'skip')",
809
+ deps.fields({ collection: plan.name }),
810
+ );
811
+ continue;
812
+ }
813
+ if (failure?.kept) {
814
+ for (const action of step.actions) {
815
+ kept.add(action.name);
816
+ deps.logger.warn(
817
+ `⚠ ${plan.name}: index "${action.name}" backs the shard key — kept, not dropped`,
818
+ deps.fields({ collection: plan.name, index: action.name }),
819
+ );
820
+ }
821
+ continue;
822
+ }
823
+ if (failure) {
824
+ const { error, actions, extra } = failure;
825
+ for (const action of actions) {
826
+ action.status = 'failed';
827
+ deps.emit('converge:action', {
828
+ collection: plan.name,
829
+ target: action.target,
830
+ name: action.name,
831
+ action: action.action,
832
+ status: 'failed',
833
+ error: errorText(error),
834
+ });
835
+ }
836
+ settleRest(result);
837
+ throw wrapFailure(error, plan.name, actions, result, extra);
838
+ }
839
+ }
840
+ return kept;
841
+ }
842
+
843
+ /**
844
+ * The verify phase — the fixed-point check: what was just applied must now
845
+ * compare as unchanged. Anything that does not would be "changed" again on
846
+ * every run — a comparison rule that disagrees with this server version — so
847
+ * it is reported in `result.unstable` instead of silently rebuilt forever.
848
+ */
849
+ async function verifyFixedPoint(run, position, kept, signal) {
850
+ const { deps, definitions, result, planFor } = run;
851
+ const definition = definitions[position];
852
+ let after = planFor(definition, await readFresh(run, position, 'apply'));
853
+ // The search index list catches up with a create or an update a moment
854
+ // later: read it again a few times before calling anything unstable.
855
+ const rows = result.collections[position].actions;
856
+ const submitted = rows.some((row) => row.target === 'searchIndex' && row.status === 'applied');
857
+ for (const delay of submitted ? SEARCH_SETTLE_DELAYS_MS : []) {
858
+ const pending = after.actions.some(
859
+ (action) => action.target === 'searchIndex' && CHANGE_ACTIONS.has(action.action),
860
+ );
861
+ if (!pending) break;
862
+ await (deps.sleep ?? pause)(delay, signal);
863
+ stopIfAborted(deps, signal, result);
864
+ after = planFor(definition, await readFresh(run, position, 'apply'));
865
+ }
866
+ refreshBuilds(rows, after.actions);
867
+ for (const action of after.actions) {
868
+ if (!CHANGE_ACTIONS.has(action.action)) continue;
869
+ if (action.action === 'drop' && kept.has(action.name)) continue;
870
+ (result.unstable ??= []).push({
871
+ collection: definition.name,
872
+ target: action.target,
873
+ name: action.name,
874
+ action: action.action,
875
+ ...(action.reason !== undefined ? { reason: action.reason } : {}),
876
+ });
877
+ // Every update of a search index has the server build it again: say what that costs.
878
+ const rebuilds =
879
+ action.target === 'searchIndex'
880
+ ? ', and every update builds the search index again on the server — declare the value ' +
881
+ 'the server reports'
882
+ : '';
883
+ deps.logger.warn(
884
+ `⚠ ${definition.name}: ${describe(action)} still differs after converge` +
885
+ `${action.reason ? ` (${action.reason})` : ''} — it would change again on every run` +
886
+ rebuilds,
887
+ deps.fields({ collection: definition.name, target: action.target, name: action.name }),
888
+ );
889
+ }
890
+ }
891
+
892
+ /** "Kept 2 undeclared index(es) and 1 search index(es)" — the parts there are */
893
+ function keptLine(kept) {
894
+ const parts = [];
895
+ if (kept.indexes > 0) parts.push(`${kept.indexes} undeclared index(es)`);
896
+ if (kept.searchIndexes > 0) {
897
+ parts.push(`${kept.searchIndexes} ${kept.indexes > 0 ? '' : 'undeclared '}search index(es)`);
898
+ }
899
+ return parts.length > 0 ? `• Kept ${parts.join(' and ')} — converge with prune to drop them` : '';
900
+ }
901
+
902
+ /** A converge that ran to the end: its closing lines, its history entry, `converge:end` */
903
+ async function reportSuccess(deps, options, result, startedAt, search) {
904
+ const { logger } = deps;
905
+ const total = result.collections.length;
906
+ const totals = finalize(result, search, true);
907
+ const durationMs = Date.now() - startedAt;
908
+ const { kept } = totals;
909
+ if (result.changed > 0) {
910
+ logger.info(
911
+ `✔ Converged ${result.changed} change(s) in ${totals.touched} of ${total} ` +
912
+ `collection(s) in ${durationMs}ms`,
913
+ deps.fields({ changed: result.changed, collections: total, durationMs }),
914
+ );
915
+ } else {
916
+ logger.info(
917
+ 'Collections already match their declarations',
918
+ deps.fields({ collections: total, durationMs }),
919
+ );
920
+ }
921
+ const line = keptLine(kept);
922
+ if (line) {
923
+ logger.info(line, deps.fields({ kept: kept.indexes + kept.searchIndexes }));
924
+ }
925
+ reportNotReady(deps, result);
926
+ await recordHistory(deps, options, result, { startedAt, history: totals.history });
927
+ deps.emit('converge:end', {
928
+ trigger: options.trigger ?? 'converge',
929
+ success: true,
930
+ durationMs,
931
+ changed: result.changed,
932
+ inSync: result.inSync,
933
+ counts: totals.counts,
934
+ result,
935
+ });
936
+ }
937
+
938
+ /** A converge that stopped: its history entry and `converge:end` — the error is the caller's */
939
+ async function reportFailure(deps, options, result, startedAt, error, search) {
940
+ const totals = finalize(result, search);
941
+ await recordHistory(deps, options, result, { startedAt, error, history: totals.history });
942
+ deps.emit('converge:end', {
943
+ trigger: options.trigger ?? 'converge',
944
+ success: false,
945
+ durationMs: Date.now() - startedAt,
946
+ changed: result.changed,
947
+ inSync: false,
948
+ counts: totals.counts,
949
+ error: errorText(error),
950
+ result,
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Plan every declared collection and, unless `dryRun`, carry the plans out:
956
+ * read → plan → guard → (per collection: re-plan → apply → verify) → report.
957
+ *
958
+ * `options`: `{ definitions, prune?, rebuildUnique?, dryRun?, trigger?, requestedBy?,
959
+ * reason?, search? }` —
960
+ * `definitions` normalized (collections.js); `prune` the default for
961
+ * definitions that do not set their own; `rebuildUnique` lets a rebuild drop a
962
+ * unique index it builds back (a conflict otherwise); `trigger` is
963
+ * `'converge'` or `'up'` (the after-up hook), for events and logs; `search`
964
+ * is `{ onUnavailable, wait, waitTimeoutMs }` — `'fail'` (the default)
965
+ * refuses declared search indexes on a server without Atlas Search, `'skip'`
966
+ * converges without them; `wait` holds the run until every declared search
967
+ * index serves its declaration, for at most `waitTimeoutMs`.
968
+ *
969
+ * A plan with any conflict refuses the whole run before the first write. A
970
+ * failed step stops the run (`ConvergeFailedError`); an abort between steps
971
+ * stops it with the abort's own error. Either way `context.converge` holds
972
+ * the result so far.
973
+ */
974
+ async function runConverge(deps, options, signal) {
975
+ const { definitions, dryRun = false, trigger = 'converge' } = options;
976
+ const startedAt = Date.now();
977
+ let planned;
978
+ try {
979
+ planned = await readAndPlan(deps, options);
980
+ } catch (error) {
981
+ throw attachConverge(error, { dryRun, changed: 0, inSync: false, collections: [] });
982
+ }
983
+ const { planFor, live, plans, search } = planned;
984
+ const result = {
985
+ dryRun,
986
+ changed: 0,
987
+ inSync: true,
988
+ collections: plans.map(({ name, actions }) => ({ name, actions })),
989
+ };
990
+ if (dryRun) return reportPlan(deps, result, search);
991
+
992
+ const wait = {
993
+ enabled: options.search?.wait === true,
994
+ timeoutMs: options.search?.waitTimeoutMs,
995
+ };
996
+ const run = { deps, definitions, live, plans, result, planFor, search, wait };
997
+ deps.emit('converge:start', { trigger, collections: definitions.length });
998
+ try {
999
+ refuseConflicts(result);
1000
+ for (const position of plans.keys()) {
1001
+ // Every collection but the first is re-read and re-planned right before
1002
+ // its turn: the ones before it may have built indexes for hours, and a
1003
+ // plan made at the start would act on a database that has moved on.
1004
+ const plan = position === 0 ? plans[0] : await replan(run, position);
1005
+ plans[position] = plan;
1006
+ result.collections[position].actions = plan.actions;
1007
+ warnRenamed(deps, plan);
1008
+ if (plan.steps.length === 0) continue;
1009
+ const kept = await applyCollection(deps, plan, result, signal, search);
1010
+ await verifyFixedPoint(run, position, kept, signal);
1011
+ }
1012
+ await waitPhase(run, signal);
1013
+ await reportSuccess(deps, options, result, startedAt, search);
1014
+ return result;
1015
+ } catch (error) {
1016
+ // Whatever stopped the run, no row is left `planned`, and the error
1017
+ // carries the result so far — a failed read included.
1018
+ settleRest(result);
1019
+ await reportFailure(deps, options, result, startedAt, error, search);
1020
+ throw attachConverge(error, result);
1021
+ }
1022
+ }
1023
+
1024
+ module.exports = { readLiveState, readLiveStates, runConverge };