@alexify/migronaut 2.1.0 → 2.3.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 (71) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +68 -10
  3. package/bullmq.d.ts +465 -7
  4. package/index.d.ts +1272 -18
  5. package/migronaut.schema.json +150 -2
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +153 -15
  11. package/src/bullmq/producer.js +202 -27
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/converge.js +38 -10
  15. package/src/cli/commands/create.js +6 -0
  16. package/src/cli/exit-codes.js +6 -0
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/table.js +68 -9
  19. package/src/core/audit.js +98 -3
  20. package/src/core/background-audit.js +139 -0
  21. package/src/core/background-drift.js +126 -0
  22. package/src/core/background-dry-run.js +366 -0
  23. package/src/core/background-engine.js +818 -0
  24. package/src/core/background-kit.js +425 -0
  25. package/src/core/background-partition.js +298 -0
  26. package/src/core/background-runner.js +305 -0
  27. package/src/core/background-sandbox.js +701 -0
  28. package/src/core/background-shard.js +542 -0
  29. package/src/core/background-spec.js +597 -0
  30. package/src/core/background-store.js +951 -0
  31. package/src/core/background-throttle.js +269 -0
  32. package/src/core/background-watch-plan.js +164 -0
  33. package/src/core/background-watch-store.js +78 -0
  34. package/src/core/background-watch.js +605 -0
  35. package/src/core/background.js +1121 -0
  36. package/src/core/bson-peer.js +23 -0
  37. package/src/core/changelog.js +32 -0
  38. package/src/core/collections.js +125 -31
  39. package/src/core/config.js +133 -13
  40. package/src/core/converge-plan.js +343 -61
  41. package/src/core/converge-search-run.js +440 -0
  42. package/src/core/converge-search.js +404 -0
  43. package/src/core/converge.js +428 -183
  44. package/src/core/index-spec.js +27 -16
  45. package/src/core/lock.js +97 -32
  46. package/src/core/migrator.js +951 -26
  47. package/src/core/options.js +32 -1
  48. package/src/core/run.js +26 -12
  49. package/src/core/runner.js +1 -1
  50. package/src/core/search-index-spec.js +758 -0
  51. package/src/core/server-info.js +70 -0
  52. package/src/core/shard-info.js +76 -0
  53. package/src/core/versioning-spec.js +181 -0
  54. package/src/errors/index.js +97 -5
  55. package/src/index.js +16 -0
  56. package/src/utils/canonical.js +34 -1
  57. package/src/utils/error.js +11 -2
  58. package/src/utils/loader.js +77 -9
  59. package/src/utils/migration-name.js +33 -1
  60. package/src/utils/telemetry.js +125 -1
  61. package/src/utils/template.js +69 -1
  62. package/src/versioning/config.js +155 -0
  63. package/src/versioning/document.js +326 -0
  64. package/src/versioning/index.js +50 -0
  65. package/src/versioning/internal.js +279 -0
  66. package/src/versioning/mongoose.js +151 -0
  67. package/src/versioning/occ.js +318 -0
  68. package/src/versioning/registry.js +187 -0
  69. package/src/versioning/upcaster.js +213 -0
  70. package/versioning.d.ts +666 -0
  71. package/versioning.js +1 -0
@@ -2,13 +2,48 @@ const { ConvergeFailedError, MigronautError } = require('../errors/index.js');
2
2
  const { pickActor } = require('../utils/actor.js');
3
3
  const { mapLimit } = require('../utils/concurrency.js');
4
4
  const { errorText } = require('../utils/error.js');
5
- const { CHANGE_ACTIONS, isDestructive, planCollection, summarize } = require('./converge-plan.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');
6
31
  const { inPlaceCapabilities } = require('./index-spec.js');
32
+ const { belowVersionFilter } = require('../versioning/document.js');
33
+ const { isVersioningIndexKey, versionFloorToCheck } = require('./versioning-spec.js');
34
+ const {
35
+ INDEX_NOT_FOUND,
36
+ NAMESPACE_NOT_FOUND,
37
+ READ_CONCURRENCY,
38
+ READ_OPTIONS,
39
+ readServer,
40
+ } = require('./server-info.js');
7
41
 
8
42
  /**
9
- * Converge: bring the declared collections' indexes and validators to their
10
- * declared state. Stateless — every run reads `listCollections` and
11
- * `listIndexes`, plans against what it finds (converge-plan.js), and carries
43
+ * Converge: bring the declared collections' indexes, search indexes and
44
+ * validators to their declared state. Stateless — every run reads
45
+ * `listCollections`, `listIndexes` (and `$listSearchIndexes` where search
46
+ * indexes are declared), plans against what it finds (converge-plan.js), and carries
12
47
  * the plan out one operation at a time. The declaration is the only source of
13
48
  * truth, and the database is checked against it afresh each time: the history
14
49
  * a run appends (converge-log.js) is for people, and nothing reads it back to
@@ -16,29 +51,14 @@ const { inPlaceCapabilities } = require('./index-spec.js');
16
51
  *
17
52
  * Pure orchestration over capabilities the MigratorKit injects (`deps`):
18
53
  * `{db, logger, fields, emit, assertNotAborted}`, and optionally `audit` +
19
- * `record` (the history entry) and `shardKeyOf` (behind a mongos).
54
+ * `record` (the history entry), `shardKeyOf` (behind a mongos), `releaseLock`
55
+ * (`() => Promise<boolean>`: give the run's lock up before waiting for search
56
+ * index builds), `recordSearchWait` (`(waitedMs, outcome)`: the wait's
57
+ * metric point), and `sleep` (`(ms, signal) => Promise`, cut short by an
58
+ * abort) and `now` (`() => ms`) — the pause between search index reads and
59
+ * the clock that times a wait for them, for tests.
20
60
  */
21
61
 
22
- /**
23
- * Read options forced onto both reads: the primary (a secondary may not have
24
- * an index build yet), and BSON values as plain JavaScript — an injected
25
- * client configured with `promoteValues: false` or `useBigInt64: true` would
26
- * otherwise hand back `Int32` objects or `1n`, and everything would compare as
27
- * changed.
28
- */
29
- const READ_OPTIONS = Object.freeze({
30
- readPreference: 'primary',
31
- promoteLongs: true,
32
- promoteValues: true,
33
- useBigInt64: false,
34
- bsonRegExp: false,
35
- });
36
-
37
- /** listIndexes calls in flight while reading many collections — a pace, not a pool */
38
- const READ_CONCURRENCY = 8;
39
-
40
- const NAMESPACE_NOT_FOUND = 26;
41
- const INDEX_NOT_FOUND = 27;
42
62
  const NAMESPACE_EXISTS = 48;
43
63
 
44
64
  /** What usually fixes the server error behind a failed step */
@@ -57,28 +77,50 @@ const HINTS = {
57
77
  };
58
78
 
59
79
  /**
60
- * What the server is: a mongos in front of shards (its shard keys matter to
61
- * prune), and its version (what it can change in place). Best-effort — a
62
- * server that refuses to say gets the conservative answer: no in-place
63
- * extras, no shard-key handling.
80
+ * How long the version floor probe may scan. With the version index it reads
81
+ * one key; without it (the index is created by the same converge that raises
82
+ * the floor) it may have to scan the collection — bounded, and a timeout
83
+ * refuses the raise rather than risk it.
64
84
  */
65
- async function readServer(db) {
66
- const server = { mongos: false, version: undefined };
67
- if (typeof db.admin !== 'function') return server;
68
- try {
69
- const hello = await db.admin().command({ hello: 1 });
70
- server.mongos = hello?.msg === 'isdbgrid';
71
- } catch {
72
- // Unknown — treated as a replica set or standalone.
85
+ const FLOOR_PROBE_TIMEOUT_MS = 60_000;
86
+
87
+ /**
88
+ * Before converge raises `versioning.min`: is any document still below it?
89
+ * Sets `live.versionFloor` to `{ min, below }` (`below: 'unknown'` with
90
+ * `error` when the probe failed) for the planner to refuse the raise on. One
91
+ * `findOne` with only `_id` projected — nothing about the document is kept.
92
+ */
93
+ async function probeVersionFloor(db, definition, live) {
94
+ const min = versionFloorToCheck(definition, live);
95
+ if (min === null) return;
96
+ const { versioning } = definition;
97
+ let hint;
98
+ for (const index of live.indexes) {
99
+ if (isVersioningIndexKey(index.key, versioning)) {
100
+ hint = index.name;
101
+ break;
102
+ }
73
103
  }
74
104
  try {
75
- const info = await db.admin().command({ buildInfo: 1 });
76
- const [major, minor] = Array.isArray(info?.versionArray) ? info.versionArray : [];
77
- if (Number.isInteger(major) && Number.isInteger(minor)) server.version = { major, minor };
78
- } catch {
79
- // Unknown version: only the always-available in-place changes.
105
+ const found = await db
106
+ .collection(definition.name)
107
+ .findOne(belowVersionFilter(versioning, min), {
108
+ projection: { _id: 1 },
109
+ maxTimeMS: FLOOR_PROBE_TIMEOUT_MS,
110
+ ...(hint !== undefined ? { hint } : {}),
111
+ ...READ_OPTIONS,
112
+ });
113
+ live.versionFloor = { min, below: found !== null };
114
+ } catch (error) {
115
+ live.versionFloor = { min, below: 'unknown', error: errorText(error) };
80
116
  }
81
- return server;
117
+ }
118
+
119
+ /** Probe every versioned collection whose floor would rise — a few at a time */
120
+ async function probeVersionFloors(db, definitions, live) {
121
+ await mapLimit([...definitions.keys()], READ_CONCURRENCY, (position) =>
122
+ probeVersionFloor(db, definitions[position], live[position]),
123
+ );
82
124
  }
83
125
 
84
126
  /** Shard key of each named collection, behind a mongos — when `config.collections` may be read */
@@ -109,6 +151,13 @@ const LABELS = {
109
151
  drop: '✔ Dropped ',
110
152
  };
111
153
 
154
+ /** A row's target as a log line names it, and what it is on */
155
+ function whatOf(action, collection) {
156
+ const target = TARGET_LABELS[action.target] ?? action.target;
157
+ const named = action.target === 'index' || action.target === 'searchIndex';
158
+ return `${target} ${named ? `${action.name} on ${collection}` : collection}`;
159
+ }
160
+
112
161
  /**
113
162
  * Every named collection's live state, in order: one `listCollections` for
114
163
  * all of them, then their `listIndexes` a few at a time — not two sequential
@@ -160,13 +209,15 @@ function attachConverge(error, result) {
160
209
 
161
210
  function describe(action) {
162
211
  if (action.target === 'index') return `index "${action.name}"`;
212
+ if (action.target === 'searchIndex') return `search index "${action.name}"`;
163
213
  return action.target === 'validator' ? 'the validator' : 'the collection';
164
214
  }
165
215
 
166
216
  /** What a failed step was doing — one row, or the indexes one command built together */
167
217
  function describeAll(actions) {
168
218
  if (actions.length === 1) return describe(actions[0]);
169
- return `indexes ${actions.map((action) => `"${action.name}"`).join(', ')}`;
219
+ const names = actions.map((action) => `"${action.name}"`).join(', ');
220
+ return actions[0].target === 'searchIndex' ? `search indexes ${names}` : `indexes ${names}`;
170
221
  }
171
222
 
172
223
  /**
@@ -207,7 +258,12 @@ function wrapFailure(error, collection, actions, result, extra = {}) {
207
258
  const action = actions[0];
208
259
  if (error instanceof MigronautError) return attachConverge(error, result);
209
260
  const mongoCode = typeof error?.code === 'number' ? error.code : undefined;
210
- const hint = mongoCode !== undefined ? HINTS[mongoCode] : undefined;
261
+ const hint =
262
+ action.target === 'searchIndex'
263
+ ? searchHint(error)
264
+ : mongoCode !== undefined
265
+ ? HINTS[mongoCode]
266
+ : undefined;
211
267
  const cause = errorText(error);
212
268
  return new ConvergeFailedError(
213
269
  `Could not ${VERBS[action.action] ?? action.action} ${describeAll(actions)} on ${collection}: ` +
@@ -314,12 +370,29 @@ async function convertToUnique(db, collection, step) {
314
370
  }
315
371
  }
316
372
 
317
- /** Carry out one planned step; returns `{ error, actions, extra }` on failure, else undefined */
318
- async function runStep(db, collection, step, settle) {
373
+ /**
374
+ * Carry out one planned step; returns `{ error, actions, extra }` on failure,
375
+ * `{ kept }` / `{ skipped }` for a step the server turned down in a way the
376
+ * run can go on from, else undefined. `search` is the run's search state.
377
+ */
378
+ async function runStep(db, collection, step, settle, search) {
319
379
  try {
320
380
  if (step.op === 'rebuild') return await runRebuild(db, collection, step, settle);
321
381
  const startedAt = Date.now();
322
- if (step.op === 'createCollection') {
382
+ if (SEARCH_STEPS.has(step.op)) {
383
+ try {
384
+ await runSearchStep(db, collection, step);
385
+ } catch (error) {
386
+ // The probe said Search was there, the server says otherwise: with
387
+ // onSearchUnavailable 'skip' that is what the configuration expects.
388
+ if (search?.onUnavailable !== 'skip' || !isSearchUnavailable(error)) throw error;
389
+ for (const action of step.actions) {
390
+ action.action = 'skip';
391
+ action.reason = SEARCH_UNAVAILABLE_REASON;
392
+ }
393
+ return { skipped: true };
394
+ }
395
+ } else if (step.op === 'createCollection') {
323
396
  try {
324
397
  await db.createCollection(collection, step.options);
325
398
  } catch (error) {
@@ -353,11 +426,9 @@ async function runStep(db, collection, step, settle) {
353
426
  for (const action of step.actions) settle(action, durationMs);
354
427
  return undefined;
355
428
  } catch (error) {
356
- return {
357
- error,
358
- actions: step.op === 'createIndexes' ? step.actions : [step.actions[0]],
359
- extra: {},
360
- };
429
+ // One command for several indexes fails (or succeeds) for all of them.
430
+ const together = step.op === 'createIndexes' || step.op === 'createSearchIndexes';
431
+ return { error, actions: together ? step.actions : [step.actions[0]], extra: {} };
361
432
  }
362
433
  }
363
434
 
@@ -371,8 +442,65 @@ function settleRest(result) {
371
442
  }
372
443
  }
373
444
 
374
- function finalize(result) {
375
- const { changes, applied, conflicts } = summarize(result.collections);
445
+ /**
446
+ * Everything the closing report needs, from one pass over every row: the
447
+ * counts behind `changed` and `inSync`, the collections touched, the
448
+ * undeclared indexes kept, a count per action kind, the rows the history
449
+ * keeps, and — when search indexes are declared — those not serving yet.
450
+ * With `settle`, a row never reached is settled first, as in settleRest.
451
+ */
452
+ function tally(result, search, settle) {
453
+ const totals = {
454
+ changes: 0,
455
+ applied: 0,
456
+ conflicts: 0,
457
+ touched: 0,
458
+ kept: { indexes: 0, searchIndexes: 0 },
459
+ counts: {},
460
+ history: [],
461
+ notReady: [],
462
+ };
463
+ const searching = search?.declared === true;
464
+ for (const collection of result.collections) {
465
+ let touched = false;
466
+ for (const action of collection.actions) {
467
+ if (settle && action.status === 'planned') {
468
+ action.status = action.action === 'conflict' ? 'failed' : 'skipped';
469
+ }
470
+ const kind = action.action;
471
+ totals.counts[kind] = (totals.counts[kind] ?? 0) + 1;
472
+ const change = CHANGE_ACTIONS.has(kind);
473
+ if (change) {
474
+ totals.changes += 1;
475
+ if (action.status === 'applied') totals.applied += 1;
476
+ if (result.dryRun || action.status === 'applied') touched = true;
477
+ } else if (kind === 'conflict') {
478
+ totals.conflicts += 1;
479
+ } else if (kind === 'keep' && action.reason === 'not declared') {
480
+ // Left in place because prune was off — not the ones that back a shard
481
+ // key (or are being deleted), for which "converge with prune" is wrong advice.
482
+ if (action.target === 'searchIndex') totals.kept.searchIndexes += 1;
483
+ else totals.kept.indexes += 1;
484
+ }
485
+ // The history keeps what changed, failed or refused the run.
486
+ if (change || kind === 'conflict' || action.status === 'failed') {
487
+ totals.history.push([collection.name, action]);
488
+ }
489
+ if (searching) {
490
+ const entry = notReadyEntry(collection.name, action);
491
+ if (entry) totals.notReady.push(entry);
492
+ }
493
+ }
494
+ if (touched) totals.touched += 1;
495
+ }
496
+ return totals;
497
+ }
498
+
499
+ /** The result's closing figures — `changed`, `inSync`, `search` — and the tally behind them */
500
+ function finalize(result, search, settle = false) {
501
+ const totals = tally(result, search, settle);
502
+ if (search?.declared) result.search = searchSummary(search, totals.notReady);
503
+ const { changes, applied, conflicts } = totals;
376
504
  if (result.dryRun) {
377
505
  result.changed = changes;
378
506
  result.inSync = changes === 0 && conflicts === 0;
@@ -380,21 +508,7 @@ function finalize(result) {
380
508
  result.changed = applied;
381
509
  result.inSync = conflicts === 0 && applied === changes && !(result.unstable?.length > 0);
382
510
  }
383
- return result;
384
- }
385
-
386
- /** Collections with at least one change — planned in a dry run, applied otherwise */
387
- function touched(result) {
388
- let count = 0;
389
- for (const collection of result.collections) {
390
- for (const action of collection.actions) {
391
- if (CHANGE_ACTIONS.has(action.action) && (result.dryRun || action.status === 'applied')) {
392
- count += 1;
393
- break;
394
- }
395
- }
396
- }
397
- return count;
511
+ return totals;
398
512
  }
399
513
 
400
514
  /** Steps that build an index — the ones that can take minutes or hours */
@@ -421,8 +535,7 @@ function announce(deps, collection, step) {
421
535
  status: 'started',
422
536
  ...(action.reason !== undefined ? { reason: action.reason } : {}),
423
537
  });
424
- const what = action.target === 'index' ? `${action.name} on ${collection}` : collection;
425
- const line = `… ${STARTING[action.action] ?? action.action} ${action.target} ${what}`;
538
+ const line = `… ${STARTING[action.action] ?? action.action} ${whatOf(action, collection)}`;
426
539
  const fields = deps.fields({
427
540
  collection,
428
541
  target: action.target,
@@ -435,31 +548,13 @@ function announce(deps, collection, step) {
435
548
  }
436
549
  }
437
550
 
438
- /** The rows worth keeping in the history: what changed, failed, or refused the run */
439
- function historyActions(result) {
440
- const actions = [];
441
- for (const collection of result.collections) {
442
- for (const action of collection.actions) {
443
- if (
444
- !CHANGE_ACTIONS.has(action.action) &&
445
- action.action !== 'conflict' &&
446
- action.status !== 'failed'
447
- ) {
448
- continue;
449
- }
450
- actions.push({ collection: collection.name, ...action });
451
- }
452
- }
453
- return actions;
454
- }
455
-
456
551
  /**
457
552
  * Append the run to the converge history — a run that changed something or
458
553
  * failed; a converge that found everything in place is not news. Best-effort,
459
554
  * like the changelog's failure trace: a history that cannot be written is
460
555
  * warned about, never allowed to turn a converge that worked into a failure.
461
556
  */
462
- async function recordHistory(deps, options, result, { startedAt, error }) {
557
+ async function recordHistory(deps, options, result, { startedAt, error, history }) {
463
558
  if (typeof deps.record !== 'function') return;
464
559
  if (error === undefined && result.changed === 0) return;
465
560
  const finishedAt = new Date();
@@ -474,8 +569,9 @@ async function recordHistory(deps, options, result, { startedAt, error }) {
474
569
  ...(error !== undefined ? { error: errorText(error) } : {}),
475
570
  ...pickActor(options),
476
571
  changed: result.changed,
477
- actions: historyActions(result),
572
+ actions: history.map(([collection, action]) => ({ collection, ...action })),
478
573
  ...(result.unstable ? { unstable: result.unstable } : {}),
574
+ ...(result.search ? { search: result.search } : {}),
479
575
  });
480
576
  } catch (recordError) {
481
577
  deps.logger.warn(
@@ -485,29 +581,6 @@ async function recordHistory(deps, options, result, { startedAt, error }) {
485
581
  }
486
582
  }
487
583
 
488
- /**
489
- * Undeclared indexes left in place because prune was off — not the ones that
490
- * back a shard key, which stay under prune too: "converge with prune to drop
491
- * them" would be wrong advice for those.
492
- */
493
- function undeclaredKept(result) {
494
- let kept = 0;
495
- for (const collection of result.collections) {
496
- for (const action of collection.actions) {
497
- if (action.action === 'keep' && action.reason === 'not declared') kept += 1;
498
- }
499
- }
500
- return kept;
501
- }
502
-
503
- function counts(result) {
504
- const out = {};
505
- for (const collection of result.collections) {
506
- for (const action of collection.actions) out[action.action] = (out[action.action] ?? 0) + 1;
507
- }
508
- return out;
509
- }
510
-
511
584
  /**
512
585
  * The read and plan phases: every declared collection's live state (one
513
586
  * read for all of them), and its plan. Behind a mongos each live state also
@@ -523,14 +596,28 @@ async function readAndPlan(deps, options) {
523
596
  const pruneFor = (definition) => definition.prune ?? options.prune ?? false;
524
597
  const server = await readServer(db);
525
598
  const capabilities = inPlaceCapabilities(server.version);
599
+ // The names to read, and whether any definition declares search indexes — one pass.
600
+ const names = new Array(definitions.length);
601
+ let declared = false;
602
+ for (const [position, definition] of definitions.entries()) {
603
+ names[position] = definition.name;
604
+ if (definition.searchIndexes !== undefined) declared = true;
605
+ }
606
+ const search = {
607
+ declared,
608
+ available: true,
609
+ onUnavailable: options.search?.onUnavailable ?? 'fail',
610
+ waitRequested: options.search?.wait === true && !options.dryRun,
611
+ };
612
+ // Reads `search` at call time: a skip at apply time turns Search off for the rest of the run.
526
613
  const planFor = (definition, live) =>
527
614
  planCollection(definition, live, {
528
615
  prune: pruneFor(definition),
529
616
  rebuildUnique: options.rebuildUnique === true,
530
617
  capabilities,
618
+ search: { available: search.available, onUnavailable: search.onUnavailable },
531
619
  });
532
620
 
533
- const names = definitions.map((definition) => definition.name);
534
621
  const live = await readLiveStates(db, names);
535
622
  if (server.mongos) {
536
623
  const shardKeys = await readShardKeys(deps, names);
@@ -538,28 +625,77 @@ async function readAndPlan(deps, options) {
538
625
  if (shardKeys.has(name)) live[position].shardKey = shardKeys.get(name);
539
626
  }
540
627
  }
541
- const plans = definitions.map((definition, position) => planFor(definition, live[position]));
542
- // `indexes: []` with prune reads as "no indexes here" — every one but _id
543
- // goes. Legitimate, and easy to write by accident: say it out loud.
628
+ if (search.declared) await readSearch(deps, server, definitions, live, search);
629
+ await probeVersionFloors(db, definitions, live);
630
+ const plans = new Array(definitions.length);
631
+ const ignored = [];
544
632
  for (const [position, definition] of definitions.entries()) {
545
- const drops = plans[position].actions.filter((action) => action.action === 'drop');
546
- if (definition.indexes?.length === 0 && pruneFor(definition) && drops.length > 0) {
547
- deps.logger.warn(
548
- `⚠ ${definition.name}: indexes: [] with prune drops every index but _id ` +
549
- `(${drops.map((action) => action.name).join(', ')})`,
550
- deps.fields({ collection: definition.name, drops: drops.length }),
551
- );
552
- }
633
+ plans[position] = planFor(definition, live[position]);
634
+ reviewPlan(deps, definition, plans[position], pruneFor(definition), ignored);
553
635
  }
554
- return { planFor, live, plans };
636
+ warnIgnored(deps, ignored);
637
+ return { planFor, live, plans, search };
638
+ }
639
+
640
+ /**
641
+ * One pass over a fresh plan for what is worth saying before anything runs:
642
+ * the search index options the comparison left out (gathered into `ignored`,
643
+ * for one line about all of them), and every drop of `indexes: []` or
644
+ * `searchIndexes: []` with prune — "none here", every index but _id goes.
645
+ * Legitimate, and easy to write by accident: say it out loud.
646
+ */
647
+ function reviewPlan(deps, definition, plan, prune, ignored) {
648
+ const emptied =
649
+ prune && (definition.indexes?.length === 0 || definition.searchIndexes?.length === 0);
650
+ const indexDrops = [];
651
+ const searchDrops = [];
652
+ for (const action of plan.actions) {
653
+ if (action.ignored) ignored.push(`${plan.name}.${action.name} (${action.ignored.join(', ')})`);
654
+ if (!emptied || action.action !== 'drop') continue;
655
+ if (action.target === 'index') indexDrops.push(action.name);
656
+ else if (action.target === 'searchIndex') searchDrops.push(action.name);
657
+ }
658
+ if (!emptied) return;
659
+ for (const [key, names, what] of [
660
+ ['indexes', indexDrops, 'every index but _id'],
661
+ ['searchIndexes', searchDrops, 'every search index'],
662
+ ]) {
663
+ if (definition[key]?.length !== 0 || names.length === 0) continue;
664
+ deps.logger.warn(
665
+ `⚠ ${definition.name}: ${key}: [] with prune drops ${what} (${names.join(', ')})`,
666
+ deps.fields({ collection: definition.name, drops: names.length }),
667
+ );
668
+ }
669
+ }
670
+
671
+ /**
672
+ * One collection's live state read afresh — its search indexes too, when it
673
+ * declares any and the server has Search — for a re-plan (`phase: 'replan'`)
674
+ * or the verify phase (`'apply'`): what a failed read is reported as.
675
+ */
676
+ async function readFresh(run, position, phase) {
677
+ const { deps, definitions, live, search } = run;
678
+ const definition = definitions[position];
679
+ const fresh = await readLiveState(deps.db, definition.name);
680
+ if (live[position].shardKey) fresh.shardKey = live[position].shardKey;
681
+ if (
682
+ search.available &&
683
+ definition.searchIndexes !== undefined &&
684
+ fresh.exists &&
685
+ fresh.type === 'collection'
686
+ ) {
687
+ fresh.searchIndexes = await readSearchIndexes(deps, definition.name, phase);
688
+ }
689
+ await probeVersionFloor(deps.db, definition, fresh);
690
+ return fresh;
555
691
  }
556
692
 
557
693
  /** A dry run's answer: the plan as the result, and one line about it */
558
- function reportPlan(deps, result) {
559
- finalize(result);
694
+ function reportPlan(deps, result, search) {
695
+ const totals = finalize(result, search);
560
696
  const total = result.collections.length;
561
697
  const line =
562
- `◎ Planned ${result.changed} change(s) in ${touched(result)} of ${total} ` + 'collection(s)';
698
+ `◎ Planned ${result.changed} change(s) in ${totals.touched} of ${total} ` + 'collection(s)';
563
699
  const fields = deps.fields({ dryRun: true, changed: result.changed, collections: total });
564
700
  // A probe that finds nothing to do (a scheduler tick, a CI gate) is not news.
565
701
  if (result.inSync) deps.logger.debug(line, fields);
@@ -570,9 +706,11 @@ function reportPlan(deps, result) {
570
706
  /** The guard phase: a plan with any conflict refuses the whole run, before the first write */
571
707
  function refuseConflicts(result) {
572
708
  const conflicts = [];
709
+ let unavailable = false;
573
710
  for (const collection of result.collections) {
574
711
  for (const action of collection.actions) {
575
712
  if (action.action !== 'conflict') continue;
713
+ if (action.reason === SEARCH_UNAVAILABLE_REASON) unavailable = true;
576
714
  conflicts.push({
577
715
  collection: collection.name,
578
716
  target: action.target,
@@ -592,8 +730,14 @@ function refuseConflicts(result) {
592
730
  ? `${conflict.collection} ${conflict.reason}`
593
731
  : `${conflict.collection} ${describe(conflict)}: ${conflict.reason}`,
594
732
  )
595
- .join('; '),
596
- { phase: 'plan', conflicts, converge: result },
733
+ .join('; ') +
734
+ (unavailable ? ` — ${SEARCH_UNAVAILABLE_HINT}` : ''),
735
+ {
736
+ phase: 'plan',
737
+ conflicts,
738
+ ...(unavailable ? { hint: SEARCH_UNAVAILABLE_HINT } : {}),
739
+ converge: result,
740
+ },
597
741
  );
598
742
  }
599
743
 
@@ -605,14 +749,13 @@ function refuseConflicts(result) {
605
749
  * touched, rather than act on what nobody reviewed.
606
750
  */
607
751
  async function replan(run, position) {
608
- const { deps, definitions, live, plans, result, planFor } = run;
752
+ const { definitions, plans, result, planFor } = run;
609
753
  const definition = definitions[position];
610
- const fresh = await readLiveState(deps.db, definition.name);
611
- if (live[position].shardKey) fresh.shardKey = live[position].shardKey;
612
- const plan = planFor(definition, fresh);
613
- const known = new Set(
614
- plans[position].actions.map((action) => `${action.target}:${action.name}:${action.action}`),
615
- );
754
+ const plan = planFor(definition, await readFresh(run, position, 'replan'));
755
+ const known = new Set();
756
+ for (const action of plans[position].actions) {
757
+ known.add(`${action.target}:${action.name}:${action.action}`);
758
+ }
616
759
  const introduced = plan.actions.filter(
617
760
  (action) =>
618
761
  (action.action === 'conflict' || isDestructive(action)) &&
@@ -665,9 +808,13 @@ function settler(deps, collection) {
665
808
  durationMs,
666
809
  ...(action.reason !== undefined ? { reason: action.reason } : {}),
667
810
  });
668
- const what = action.target === 'index' ? `${action.name} on ${collection}` : collection;
811
+ // A search index is only accepted here; the server builds it afterwards.
812
+ const building =
813
+ action.target === 'searchIndex' && action.action !== 'drop'
814
+ ? ' — building on the server'
815
+ : '';
669
816
  deps.logger.info(
670
- `${LABELS[action.action]} ${action.target} ${what} [${durationMs}ms]`,
817
+ `${LABELS[action.action]} ${whatOf(action, collection)} [${durationMs}ms]${building}`,
671
818
  deps.fields({
672
819
  collection,
673
820
  target: action.target,
@@ -679,25 +826,41 @@ function settler(deps, collection) {
679
826
  };
680
827
  }
681
828
 
829
+ /** Stop here when the run was aborted: every row not reached is settled, the result attached */
830
+ function stopIfAborted(deps, signal, result) {
831
+ try {
832
+ deps.assertNotAborted(signal);
833
+ } catch (error) {
834
+ settleRest(result);
835
+ throw attachConverge(error, result);
836
+ }
837
+ }
838
+
682
839
  /**
683
840
  * The apply phase for one collection: its steps, in order. Returns the
684
841
  * indexes the server would not let go of (a shard key's), which the verify
685
842
  * phase must not report as unstable. A failed step stops the run.
686
843
  */
687
- async function applyCollection(deps, plan, result, signal) {
844
+ async function applyCollection(deps, plan, result, signal, search) {
688
845
  const kept = new Set();
689
846
  const settle = settler(deps, plan.name);
690
847
  for (const step of plan.steps) {
691
848
  // Between operations is the only safe place to stop — and never inside
692
849
  // a rebuild, which runs its drop and its create back to back.
693
- try {
694
- deps.assertNotAborted(signal);
695
- } catch (error) {
696
- settleRest(result);
697
- throw attachConverge(error, result);
698
- }
850
+ stopIfAborted(deps, signal, result);
699
851
  announce(deps, plan.name, step);
700
- const failure = await runStep(deps.db, plan.name, step, settle);
852
+ const failure = await runStep(deps.db, plan.name, step, settle, search);
853
+ if (failure?.skipped) {
854
+ // Search turned out to be missing after all: the rest of the run plans
855
+ // every search index as skipped instead of asking again.
856
+ search.available = false;
857
+ deps.logger.warn(
858
+ `⚠ ${plan.name}: Atlas Search refused ${describeAll(step.actions)} — skipped ` +
859
+ "(onSearchUnavailable: 'skip')",
860
+ deps.fields({ collection: plan.name }),
861
+ );
862
+ continue;
863
+ }
701
864
  if (failure?.kept) {
702
865
  for (const action of step.actions) {
703
866
  kept.add(action.name);
@@ -728,18 +891,67 @@ async function applyCollection(deps, plan, result, signal) {
728
891
  return kept;
729
892
  }
730
893
 
894
+ /**
895
+ * After a run raised `versioning.min`: old-shape documents written while it
896
+ * ran (an old pod still deploying) got past the probe. Converge cannot undo
897
+ * the validator, so it says so — those documents now fail validation on
898
+ * their next strict write, and a background migration has to pick them up.
899
+ */
900
+ async function warnFloorBreach(run, position) {
901
+ const { deps, definitions, live, result } = run;
902
+ const floor = live[position].versionFloor;
903
+ if (floor?.below !== false) return;
904
+ const raised = result.collections[position].actions.some(
905
+ (action) => action.target === 'validator' && action.status === 'applied',
906
+ );
907
+ if (!raised) return;
908
+ const definition = definitions[position];
909
+ let found;
910
+ try {
911
+ found = await deps.db
912
+ .collection(definition.name)
913
+ .findOne(belowVersionFilter(definition.versioning, floor.min), {
914
+ projection: { _id: 1 },
915
+ maxTimeMS: FLOOR_PROBE_TIMEOUT_MS,
916
+ ...READ_OPTIONS,
917
+ });
918
+ } catch {
919
+ return;
920
+ }
921
+ if (found === null) return;
922
+ deps.logger.warn(
923
+ `⚠ ${definition.name}: documents below version ${floor.min} were written while converge ` +
924
+ 'raised versioning.min — an old release is still writing; run the background migration ' +
925
+ 'again once it is gone',
926
+ deps.fields({ collection: definition.name, min: floor.min }),
927
+ );
928
+ }
929
+
731
930
  /**
732
931
  * The verify phase — the fixed-point check: what was just applied must now
733
932
  * compare as unchanged. Anything that does not would be "changed" again on
734
933
  * every run — a comparison rule that disagrees with this server version — so
735
934
  * it is reported in `result.unstable` instead of silently rebuilt forever.
736
935
  */
737
- async function verifyFixedPoint(run, position, kept) {
738
- const { deps, definitions, live, result, planFor } = run;
936
+ async function verifyFixedPoint(run, position, kept, signal) {
937
+ const { deps, definitions, result, planFor } = run;
739
938
  const definition = definitions[position];
740
- const afterLive = await readLiveState(deps.db, definition.name);
741
- if (live[position].shardKey) afterLive.shardKey = live[position].shardKey;
742
- const after = planFor(definition, afterLive);
939
+ let after = planFor(definition, await readFresh(run, position, 'apply'));
940
+ // The search index list catches up with a create or an update a moment
941
+ // later: read it again a few times before calling anything unstable.
942
+ const rows = result.collections[position].actions;
943
+ const submitted = rows.some((row) => row.target === 'searchIndex' && row.status === 'applied');
944
+ for (const delay of submitted ? SEARCH_SETTLE_DELAYS_MS : []) {
945
+ const pending = after.actions.some(
946
+ (action) => action.target === 'searchIndex' && CHANGE_ACTIONS.has(action.action),
947
+ );
948
+ if (!pending) break;
949
+ await (deps.sleep ?? pause)(delay, signal);
950
+ stopIfAborted(deps, signal, result);
951
+ after = planFor(definition, await readFresh(run, position, 'apply'));
952
+ }
953
+ refreshBuilds(rows, after.actions);
954
+ await warnFloorBreach(run, position);
743
955
  for (const action of after.actions) {
744
956
  if (!CHANGE_ACTIONS.has(action.action)) continue;
745
957
  if (action.action === 'drop' && kept.has(action.name)) continue;
@@ -750,25 +962,41 @@ async function verifyFixedPoint(run, position, kept) {
750
962
  action: action.action,
751
963
  ...(action.reason !== undefined ? { reason: action.reason } : {}),
752
964
  });
965
+ // Every update of a search index has the server build it again: say what that costs.
966
+ const rebuilds =
967
+ action.target === 'searchIndex'
968
+ ? ', and every update builds the search index again on the server — declare the value ' +
969
+ 'the server reports'
970
+ : '';
753
971
  deps.logger.warn(
754
972
  `⚠ ${definition.name}: ${describe(action)} still differs after converge` +
755
- `${action.reason ? ` (${action.reason})` : ''} — it would change again on every run`,
973
+ `${action.reason ? ` (${action.reason})` : ''} — it would change again on every run` +
974
+ rebuilds,
756
975
  deps.fields({ collection: definition.name, target: action.target, name: action.name }),
757
976
  );
758
977
  }
759
978
  }
760
979
 
980
+ /** "Kept 2 undeclared index(es) and 1 search index(es)" — the parts there are */
981
+ function keptLine(kept) {
982
+ const parts = [];
983
+ if (kept.indexes > 0) parts.push(`${kept.indexes} undeclared index(es)`);
984
+ if (kept.searchIndexes > 0) {
985
+ parts.push(`${kept.searchIndexes} ${kept.indexes > 0 ? '' : 'undeclared '}search index(es)`);
986
+ }
987
+ return parts.length > 0 ? `• Kept ${parts.join(' and ')} — converge with prune to drop them` : '';
988
+ }
989
+
761
990
  /** A converge that ran to the end: its closing lines, its history entry, `converge:end` */
762
- async function reportSuccess(deps, options, result, startedAt) {
991
+ async function reportSuccess(deps, options, result, startedAt, search) {
763
992
  const { logger } = deps;
764
993
  const total = result.collections.length;
765
- settleRest(result);
766
- finalize(result);
994
+ const totals = finalize(result, search, true);
767
995
  const durationMs = Date.now() - startedAt;
768
- const kept = undeclaredKept(result);
996
+ const { kept } = totals;
769
997
  if (result.changed > 0) {
770
998
  logger.info(
771
- `✔ Converged ${result.changed} change(s) in ${touched(result)} of ${total} ` +
999
+ `✔ Converged ${result.changed} change(s) in ${totals.touched} of ${total} ` +
772
1000
  `collection(s) in ${durationMs}ms`,
773
1001
  deps.fields({ changed: result.changed, collections: total, durationMs }),
774
1002
  );
@@ -778,35 +1006,34 @@ async function reportSuccess(deps, options, result, startedAt) {
778
1006
  deps.fields({ collections: total, durationMs }),
779
1007
  );
780
1008
  }
781
- if (kept > 0) {
782
- logger.info(
783
- `• Kept ${kept} undeclared index(es) — converge with prune to drop them`,
784
- deps.fields({ kept }),
785
- );
1009
+ const line = keptLine(kept);
1010
+ if (line) {
1011
+ logger.info(line, deps.fields({ kept: kept.indexes + kept.searchIndexes }));
786
1012
  }
787
- await recordHistory(deps, options, result, { startedAt });
1013
+ reportNotReady(deps, result);
1014
+ await recordHistory(deps, options, result, { startedAt, history: totals.history });
788
1015
  deps.emit('converge:end', {
789
1016
  trigger: options.trigger ?? 'converge',
790
1017
  success: true,
791
1018
  durationMs,
792
1019
  changed: result.changed,
793
1020
  inSync: result.inSync,
794
- counts: counts(result),
1021
+ counts: totals.counts,
795
1022
  result,
796
1023
  });
797
1024
  }
798
1025
 
799
1026
  /** A converge that stopped: its history entry and `converge:end` — the error is the caller's */
800
- async function reportFailure(deps, options, result, startedAt, error) {
801
- finalize(result);
802
- await recordHistory(deps, options, result, { startedAt, error });
1027
+ async function reportFailure(deps, options, result, startedAt, error, search) {
1028
+ const totals = finalize(result, search);
1029
+ await recordHistory(deps, options, result, { startedAt, error, history: totals.history });
803
1030
  deps.emit('converge:end', {
804
1031
  trigger: options.trigger ?? 'converge',
805
1032
  success: false,
806
1033
  durationMs: Date.now() - startedAt,
807
1034
  changed: result.changed,
808
1035
  inSync: false,
809
- counts: counts(result),
1036
+ counts: totals.counts,
810
1037
  error: errorText(error),
811
1038
  result,
812
1039
  });
@@ -817,11 +1044,15 @@ async function reportFailure(deps, options, result, startedAt, error) {
817
1044
  * read → plan → guard → (per collection: re-plan → apply → verify) → report.
818
1045
  *
819
1046
  * `options`: `{ definitions, prune?, rebuildUnique?, dryRun?, trigger?, requestedBy?,
820
- * reason? }` —
1047
+ * reason?, search? }` —
821
1048
  * `definitions` normalized (collections.js); `prune` the default for
822
1049
  * definitions that do not set their own; `rebuildUnique` lets a rebuild drop a
823
1050
  * unique index it builds back (a conflict otherwise); `trigger` is
824
- * `'converge'` or `'up'` (the after-up hook), for events and logs.
1051
+ * `'converge'` or `'up'` (the after-up hook), for events and logs; `search`
1052
+ * is `{ onUnavailable, wait, waitTimeoutMs }` — `'fail'` (the default)
1053
+ * refuses declared search indexes on a server without Atlas Search, `'skip'`
1054
+ * converges without them; `wait` holds the run until every declared search
1055
+ * index serves its declaration, for at most `waitTimeoutMs`.
825
1056
  *
826
1057
  * A plan with any conflict refuses the whole run before the first write. A
827
1058
  * failed step stops the run (`ConvergeFailedError`); an abort between steps
@@ -831,16 +1062,26 @@ async function reportFailure(deps, options, result, startedAt, error) {
831
1062
  async function runConverge(deps, options, signal) {
832
1063
  const { definitions, dryRun = false, trigger = 'converge' } = options;
833
1064
  const startedAt = Date.now();
834
- const { planFor, live, plans } = await readAndPlan(deps, options);
1065
+ let planned;
1066
+ try {
1067
+ planned = await readAndPlan(deps, options);
1068
+ } catch (error) {
1069
+ throw attachConverge(error, { dryRun, changed: 0, inSync: false, collections: [] });
1070
+ }
1071
+ const { planFor, live, plans, search } = planned;
835
1072
  const result = {
836
1073
  dryRun,
837
1074
  changed: 0,
838
1075
  inSync: true,
839
1076
  collections: plans.map(({ name, actions }) => ({ name, actions })),
840
1077
  };
841
- if (dryRun) return reportPlan(deps, result);
1078
+ if (dryRun) return reportPlan(deps, result, search);
842
1079
 
843
- const run = { deps, definitions, live, plans, result, planFor };
1080
+ const wait = {
1081
+ enabled: options.search?.wait === true,
1082
+ timeoutMs: options.search?.waitTimeoutMs,
1083
+ };
1084
+ const run = { deps, definitions, live, plans, result, planFor, search, wait };
844
1085
  deps.emit('converge:start', { trigger, collections: definitions.length });
845
1086
  try {
846
1087
  refuseConflicts(result);
@@ -853,15 +1094,19 @@ async function runConverge(deps, options, signal) {
853
1094
  result.collections[position].actions = plan.actions;
854
1095
  warnRenamed(deps, plan);
855
1096
  if (plan.steps.length === 0) continue;
856
- const kept = await applyCollection(deps, plan, result, signal);
857
- await verifyFixedPoint(run, position, kept);
1097
+ const kept = await applyCollection(deps, plan, result, signal, search);
1098
+ await verifyFixedPoint(run, position, kept, signal);
858
1099
  }
859
- await reportSuccess(deps, options, result, startedAt);
1100
+ await waitPhase(run, signal);
1101
+ await reportSuccess(deps, options, result, startedAt, search);
860
1102
  return result;
861
1103
  } catch (error) {
862
- await reportFailure(deps, options, result, startedAt, error);
863
- throw error;
1104
+ // Whatever stopped the run, no row is left `planned`, and the error
1105
+ // carries the result so far — a failed read included.
1106
+ settleRest(result);
1107
+ await reportFailure(deps, options, result, startedAt, error, search);
1108
+ throw attachConverge(error, result);
864
1109
  }
865
1110
  }
866
1111
 
867
- module.exports = { READ_OPTIONS, readLiveState, readLiveStates, runConverge };
1112
+ module.exports = { readLiveState, readLiveStates, runConverge };