@alexify/migronaut 2.1.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.
- package/CHANGELOG.md +116 -0
- package/README.md +35 -8
- package/bullmq.d.ts +16 -1
- package/index.d.ts +266 -13
- package/migronaut.schema.json +57 -1
- package/package.json +1 -1
- package/src/bullmq/processor.js +25 -1
- package/src/bullmq/producer.js +17 -14
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +88 -3
- package/src/core/collections.js +50 -26
- package/src/core/config.js +31 -1
- package/src/core/converge-plan.js +260 -57
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +347 -190
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +50 -12
- package/src/core/migrator.js +47 -14
- package/src/core/options.js +16 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +9 -5
- package/src/utils/canonical.js +34 -1
- package/src/utils/telemetry.js +18 -1
- package/src/utils/template.js +7 -0
package/src/core/converge.js
CHANGED
|
@@ -2,13 +2,46 @@ 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 {
|
|
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 {
|
|
33
|
+
INDEX_NOT_FOUND,
|
|
34
|
+
NAMESPACE_NOT_FOUND,
|
|
35
|
+
READ_CONCURRENCY,
|
|
36
|
+
READ_OPTIONS,
|
|
37
|
+
readServer,
|
|
38
|
+
} = require('./server-info.js');
|
|
7
39
|
|
|
8
40
|
/**
|
|
9
|
-
* Converge: bring the declared collections' indexes
|
|
10
|
-
* declared state. Stateless — every run reads
|
|
11
|
-
* `
|
|
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
|
|
12
45
|
* the plan out one operation at a time. The declaration is the only source of
|
|
13
46
|
* truth, and the database is checked against it afresh each time: the history
|
|
14
47
|
* a run appends (converge-log.js) is for people, and nothing reads it back to
|
|
@@ -16,29 +49,14 @@ const { inPlaceCapabilities } = require('./index-spec.js');
|
|
|
16
49
|
*
|
|
17
50
|
* Pure orchestration over capabilities the MigratorKit injects (`deps`):
|
|
18
51
|
* `{db, logger, fields, emit, assertNotAborted}`, and optionally `audit` +
|
|
19
|
-
* `record` (the history entry)
|
|
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.
|
|
20
58
|
*/
|
|
21
59
|
|
|
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
60
|
const NAMESPACE_EXISTS = 48;
|
|
43
61
|
|
|
44
62
|
/** What usually fixes the server error behind a failed step */
|
|
@@ -56,31 +74,6 @@ const HINTS = {
|
|
|
56
74
|
'first (the index was left as it was)',
|
|
57
75
|
};
|
|
58
76
|
|
|
59
|
-
/**
|
|
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.
|
|
64
|
-
*/
|
|
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.
|
|
73
|
-
}
|
|
74
|
-
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.
|
|
80
|
-
}
|
|
81
|
-
return server;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
77
|
/** Shard key of each named collection, behind a mongos — when `config.collections` may be read */
|
|
85
78
|
async function readShardKeys(deps, names) {
|
|
86
79
|
const keys = new Map();
|
|
@@ -109,6 +102,13 @@ const LABELS = {
|
|
|
109
102
|
drop: '✔ Dropped ',
|
|
110
103
|
};
|
|
111
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
112
|
/**
|
|
113
113
|
* Every named collection's live state, in order: one `listCollections` for
|
|
114
114
|
* all of them, then their `listIndexes` a few at a time — not two sequential
|
|
@@ -160,13 +160,15 @@ function attachConverge(error, result) {
|
|
|
160
160
|
|
|
161
161
|
function describe(action) {
|
|
162
162
|
if (action.target === 'index') return `index "${action.name}"`;
|
|
163
|
+
if (action.target === 'searchIndex') return `search index "${action.name}"`;
|
|
163
164
|
return action.target === 'validator' ? 'the validator' : 'the collection';
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/** What a failed step was doing — one row, or the indexes one command built together */
|
|
167
168
|
function describeAll(actions) {
|
|
168
169
|
if (actions.length === 1) return describe(actions[0]);
|
|
169
|
-
|
|
170
|
+
const names = actions.map((action) => `"${action.name}"`).join(', ');
|
|
171
|
+
return actions[0].target === 'searchIndex' ? `search indexes ${names}` : `indexes ${names}`;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
/**
|
|
@@ -207,7 +209,12 @@ function wrapFailure(error, collection, actions, result, extra = {}) {
|
|
|
207
209
|
const action = actions[0];
|
|
208
210
|
if (error instanceof MigronautError) return attachConverge(error, result);
|
|
209
211
|
const mongoCode = typeof error?.code === 'number' ? error.code : undefined;
|
|
210
|
-
const hint =
|
|
212
|
+
const hint =
|
|
213
|
+
action.target === 'searchIndex'
|
|
214
|
+
? searchHint(error)
|
|
215
|
+
: mongoCode !== undefined
|
|
216
|
+
? HINTS[mongoCode]
|
|
217
|
+
: undefined;
|
|
211
218
|
const cause = errorText(error);
|
|
212
219
|
return new ConvergeFailedError(
|
|
213
220
|
`Could not ${VERBS[action.action] ?? action.action} ${describeAll(actions)} on ${collection}: ` +
|
|
@@ -314,12 +321,29 @@ async function convertToUnique(db, collection, step) {
|
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
/**
|
|
318
|
-
|
|
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) {
|
|
319
330
|
try {
|
|
320
331
|
if (step.op === 'rebuild') return await runRebuild(db, collection, step, settle);
|
|
321
332
|
const startedAt = Date.now();
|
|
322
|
-
if (step.op
|
|
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') {
|
|
323
347
|
try {
|
|
324
348
|
await db.createCollection(collection, step.options);
|
|
325
349
|
} catch (error) {
|
|
@@ -353,11 +377,9 @@ async function runStep(db, collection, step, settle) {
|
|
|
353
377
|
for (const action of step.actions) settle(action, durationMs);
|
|
354
378
|
return undefined;
|
|
355
379
|
} catch (error) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
extra: {},
|
|
360
|
-
};
|
|
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: {} };
|
|
361
383
|
}
|
|
362
384
|
}
|
|
363
385
|
|
|
@@ -371,8 +393,65 @@ function settleRest(result) {
|
|
|
371
393
|
}
|
|
372
394
|
}
|
|
373
395
|
|
|
374
|
-
|
|
375
|
-
|
|
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;
|
|
376
455
|
if (result.dryRun) {
|
|
377
456
|
result.changed = changes;
|
|
378
457
|
result.inSync = changes === 0 && conflicts === 0;
|
|
@@ -380,21 +459,7 @@ function finalize(result) {
|
|
|
380
459
|
result.changed = applied;
|
|
381
460
|
result.inSync = conflicts === 0 && applied === changes && !(result.unstable?.length > 0);
|
|
382
461
|
}
|
|
383
|
-
return
|
|
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;
|
|
462
|
+
return totals;
|
|
398
463
|
}
|
|
399
464
|
|
|
400
465
|
/** Steps that build an index — the ones that can take minutes or hours */
|
|
@@ -421,8 +486,7 @@ function announce(deps, collection, step) {
|
|
|
421
486
|
status: 'started',
|
|
422
487
|
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
423
488
|
});
|
|
424
|
-
const
|
|
425
|
-
const line = `… ${STARTING[action.action] ?? action.action} ${action.target} ${what}`;
|
|
489
|
+
const line = `… ${STARTING[action.action] ?? action.action} ${whatOf(action, collection)}`;
|
|
426
490
|
const fields = deps.fields({
|
|
427
491
|
collection,
|
|
428
492
|
target: action.target,
|
|
@@ -435,31 +499,13 @@ function announce(deps, collection, step) {
|
|
|
435
499
|
}
|
|
436
500
|
}
|
|
437
501
|
|
|
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
502
|
/**
|
|
457
503
|
* Append the run to the converge history — a run that changed something or
|
|
458
504
|
* failed; a converge that found everything in place is not news. Best-effort,
|
|
459
505
|
* like the changelog's failure trace: a history that cannot be written is
|
|
460
506
|
* warned about, never allowed to turn a converge that worked into a failure.
|
|
461
507
|
*/
|
|
462
|
-
async function recordHistory(deps, options, result, { startedAt, error }) {
|
|
508
|
+
async function recordHistory(deps, options, result, { startedAt, error, history }) {
|
|
463
509
|
if (typeof deps.record !== 'function') return;
|
|
464
510
|
if (error === undefined && result.changed === 0) return;
|
|
465
511
|
const finishedAt = new Date();
|
|
@@ -474,8 +520,9 @@ async function recordHistory(deps, options, result, { startedAt, error }) {
|
|
|
474
520
|
...(error !== undefined ? { error: errorText(error) } : {}),
|
|
475
521
|
...pickActor(options),
|
|
476
522
|
changed: result.changed,
|
|
477
|
-
actions:
|
|
523
|
+
actions: history.map(([collection, action]) => ({ collection, ...action })),
|
|
478
524
|
...(result.unstable ? { unstable: result.unstable } : {}),
|
|
525
|
+
...(result.search ? { search: result.search } : {}),
|
|
479
526
|
});
|
|
480
527
|
} catch (recordError) {
|
|
481
528
|
deps.logger.warn(
|
|
@@ -485,29 +532,6 @@ async function recordHistory(deps, options, result, { startedAt, error }) {
|
|
|
485
532
|
}
|
|
486
533
|
}
|
|
487
534
|
|
|
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
535
|
/**
|
|
512
536
|
* The read and plan phases: every declared collection's live state (one
|
|
513
537
|
* read for all of them), and its plan. Behind a mongos each live state also
|
|
@@ -523,14 +547,28 @@ async function readAndPlan(deps, options) {
|
|
|
523
547
|
const pruneFor = (definition) => definition.prune ?? options.prune ?? false;
|
|
524
548
|
const server = await readServer(db);
|
|
525
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.
|
|
526
564
|
const planFor = (definition, live) =>
|
|
527
565
|
planCollection(definition, live, {
|
|
528
566
|
prune: pruneFor(definition),
|
|
529
567
|
rebuildUnique: options.rebuildUnique === true,
|
|
530
568
|
capabilities,
|
|
569
|
+
search: { available: search.available, onUnavailable: search.onUnavailable },
|
|
531
570
|
});
|
|
532
571
|
|
|
533
|
-
const names = definitions.map((definition) => definition.name);
|
|
534
572
|
const live = await readLiveStates(db, names);
|
|
535
573
|
if (server.mongos) {
|
|
536
574
|
const shardKeys = await readShardKeys(deps, names);
|
|
@@ -538,28 +576,75 @@ async function readAndPlan(deps, options) {
|
|
|
538
576
|
if (shardKeys.has(name)) live[position].shardKey = shardKeys.get(name);
|
|
539
577
|
}
|
|
540
578
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
579
|
+
if (search.declared) await readSearch(deps, server, definitions, live, search);
|
|
580
|
+
const plans = new Array(definitions.length);
|
|
581
|
+
const ignored = [];
|
|
544
582
|
for (const [position, definition] of definitions.entries()) {
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
}
|
|
583
|
+
plans[position] = planFor(definition, live[position]);
|
|
584
|
+
reviewPlan(deps, definition, plans[position], pruneFor(definition), ignored);
|
|
553
585
|
}
|
|
554
|
-
|
|
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;
|
|
555
640
|
}
|
|
556
641
|
|
|
557
642
|
/** A dry run's answer: the plan as the result, and one line about it */
|
|
558
|
-
function reportPlan(deps, result) {
|
|
559
|
-
finalize(result);
|
|
643
|
+
function reportPlan(deps, result, search) {
|
|
644
|
+
const totals = finalize(result, search);
|
|
560
645
|
const total = result.collections.length;
|
|
561
646
|
const line =
|
|
562
|
-
`◎ Planned ${result.changed} change(s) in ${touched
|
|
647
|
+
`◎ Planned ${result.changed} change(s) in ${totals.touched} of ${total} ` + 'collection(s)';
|
|
563
648
|
const fields = deps.fields({ dryRun: true, changed: result.changed, collections: total });
|
|
564
649
|
// A probe that finds nothing to do (a scheduler tick, a CI gate) is not news.
|
|
565
650
|
if (result.inSync) deps.logger.debug(line, fields);
|
|
@@ -570,9 +655,11 @@ function reportPlan(deps, result) {
|
|
|
570
655
|
/** The guard phase: a plan with any conflict refuses the whole run, before the first write */
|
|
571
656
|
function refuseConflicts(result) {
|
|
572
657
|
const conflicts = [];
|
|
658
|
+
let unavailable = false;
|
|
573
659
|
for (const collection of result.collections) {
|
|
574
660
|
for (const action of collection.actions) {
|
|
575
661
|
if (action.action !== 'conflict') continue;
|
|
662
|
+
if (action.reason === SEARCH_UNAVAILABLE_REASON) unavailable = true;
|
|
576
663
|
conflicts.push({
|
|
577
664
|
collection: collection.name,
|
|
578
665
|
target: action.target,
|
|
@@ -592,8 +679,14 @@ function refuseConflicts(result) {
|
|
|
592
679
|
? `${conflict.collection} ${conflict.reason}`
|
|
593
680
|
: `${conflict.collection} ${describe(conflict)}: ${conflict.reason}`,
|
|
594
681
|
)
|
|
595
|
-
.join('; ')
|
|
596
|
-
|
|
682
|
+
.join('; ') +
|
|
683
|
+
(unavailable ? ` — ${SEARCH_UNAVAILABLE_HINT}` : ''),
|
|
684
|
+
{
|
|
685
|
+
phase: 'plan',
|
|
686
|
+
conflicts,
|
|
687
|
+
...(unavailable ? { hint: SEARCH_UNAVAILABLE_HINT } : {}),
|
|
688
|
+
converge: result,
|
|
689
|
+
},
|
|
597
690
|
);
|
|
598
691
|
}
|
|
599
692
|
|
|
@@ -605,14 +698,13 @@ function refuseConflicts(result) {
|
|
|
605
698
|
* touched, rather than act on what nobody reviewed.
|
|
606
699
|
*/
|
|
607
700
|
async function replan(run, position) {
|
|
608
|
-
const {
|
|
701
|
+
const { definitions, plans, result, planFor } = run;
|
|
609
702
|
const definition = definitions[position];
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
);
|
|
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
|
+
}
|
|
616
708
|
const introduced = plan.actions.filter(
|
|
617
709
|
(action) =>
|
|
618
710
|
(action.action === 'conflict' || isDestructive(action)) &&
|
|
@@ -665,9 +757,13 @@ function settler(deps, collection) {
|
|
|
665
757
|
durationMs,
|
|
666
758
|
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
667
759
|
});
|
|
668
|
-
|
|
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
|
+
: '';
|
|
669
765
|
deps.logger.info(
|
|
670
|
-
`${LABELS[action.action]} ${action
|
|
766
|
+
`${LABELS[action.action]} ${whatOf(action, collection)} [${durationMs}ms]${building}`,
|
|
671
767
|
deps.fields({
|
|
672
768
|
collection,
|
|
673
769
|
target: action.target,
|
|
@@ -679,25 +775,41 @@ function settler(deps, collection) {
|
|
|
679
775
|
};
|
|
680
776
|
}
|
|
681
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
|
+
|
|
682
788
|
/**
|
|
683
789
|
* The apply phase for one collection: its steps, in order. Returns the
|
|
684
790
|
* indexes the server would not let go of (a shard key's), which the verify
|
|
685
791
|
* phase must not report as unstable. A failed step stops the run.
|
|
686
792
|
*/
|
|
687
|
-
async function applyCollection(deps, plan, result, signal) {
|
|
793
|
+
async function applyCollection(deps, plan, result, signal, search) {
|
|
688
794
|
const kept = new Set();
|
|
689
795
|
const settle = settler(deps, plan.name);
|
|
690
796
|
for (const step of plan.steps) {
|
|
691
797
|
// Between operations is the only safe place to stop — and never inside
|
|
692
798
|
// a rebuild, which runs its drop and its create back to back.
|
|
693
|
-
|
|
694
|
-
deps.assertNotAborted(signal);
|
|
695
|
-
} catch (error) {
|
|
696
|
-
settleRest(result);
|
|
697
|
-
throw attachConverge(error, result);
|
|
698
|
-
}
|
|
799
|
+
stopIfAborted(deps, signal, result);
|
|
699
800
|
announce(deps, plan.name, step);
|
|
700
|
-
const failure = await runStep(deps.db, plan.name, step, settle);
|
|
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
|
+
}
|
|
701
813
|
if (failure?.kept) {
|
|
702
814
|
for (const action of step.actions) {
|
|
703
815
|
kept.add(action.name);
|
|
@@ -734,12 +846,24 @@ async function applyCollection(deps, plan, result, signal) {
|
|
|
734
846
|
* every run — a comparison rule that disagrees with this server version — so
|
|
735
847
|
* it is reported in `result.unstable` instead of silently rebuilt forever.
|
|
736
848
|
*/
|
|
737
|
-
async function verifyFixedPoint(run, position, kept) {
|
|
738
|
-
const { deps, definitions,
|
|
849
|
+
async function verifyFixedPoint(run, position, kept, signal) {
|
|
850
|
+
const { deps, definitions, result, planFor } = run;
|
|
739
851
|
const definition = definitions[position];
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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);
|
|
743
867
|
for (const action of after.actions) {
|
|
744
868
|
if (!CHANGE_ACTIONS.has(action.action)) continue;
|
|
745
869
|
if (action.action === 'drop' && kept.has(action.name)) continue;
|
|
@@ -750,25 +874,41 @@ async function verifyFixedPoint(run, position, kept) {
|
|
|
750
874
|
action: action.action,
|
|
751
875
|
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
752
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
|
+
: '';
|
|
753
883
|
deps.logger.warn(
|
|
754
884
|
`⚠ ${definition.name}: ${describe(action)} still differs after converge` +
|
|
755
|
-
`${action.reason ? ` (${action.reason})` : ''} — it would change again on every run
|
|
885
|
+
`${action.reason ? ` (${action.reason})` : ''} — it would change again on every run` +
|
|
886
|
+
rebuilds,
|
|
756
887
|
deps.fields({ collection: definition.name, target: action.target, name: action.name }),
|
|
757
888
|
);
|
|
758
889
|
}
|
|
759
890
|
}
|
|
760
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
|
+
|
|
761
902
|
/** A converge that ran to the end: its closing lines, its history entry, `converge:end` */
|
|
762
|
-
async function reportSuccess(deps, options, result, startedAt) {
|
|
903
|
+
async function reportSuccess(deps, options, result, startedAt, search) {
|
|
763
904
|
const { logger } = deps;
|
|
764
905
|
const total = result.collections.length;
|
|
765
|
-
|
|
766
|
-
finalize(result);
|
|
906
|
+
const totals = finalize(result, search, true);
|
|
767
907
|
const durationMs = Date.now() - startedAt;
|
|
768
|
-
const kept =
|
|
908
|
+
const { kept } = totals;
|
|
769
909
|
if (result.changed > 0) {
|
|
770
910
|
logger.info(
|
|
771
|
-
`✔ Converged ${result.changed} change(s) in ${touched
|
|
911
|
+
`✔ Converged ${result.changed} change(s) in ${totals.touched} of ${total} ` +
|
|
772
912
|
`collection(s) in ${durationMs}ms`,
|
|
773
913
|
deps.fields({ changed: result.changed, collections: total, durationMs }),
|
|
774
914
|
);
|
|
@@ -778,35 +918,34 @@ async function reportSuccess(deps, options, result, startedAt) {
|
|
|
778
918
|
deps.fields({ collections: total, durationMs }),
|
|
779
919
|
);
|
|
780
920
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
deps.fields({ kept }),
|
|
785
|
-
);
|
|
921
|
+
const line = keptLine(kept);
|
|
922
|
+
if (line) {
|
|
923
|
+
logger.info(line, deps.fields({ kept: kept.indexes + kept.searchIndexes }));
|
|
786
924
|
}
|
|
787
|
-
|
|
925
|
+
reportNotReady(deps, result);
|
|
926
|
+
await recordHistory(deps, options, result, { startedAt, history: totals.history });
|
|
788
927
|
deps.emit('converge:end', {
|
|
789
928
|
trigger: options.trigger ?? 'converge',
|
|
790
929
|
success: true,
|
|
791
930
|
durationMs,
|
|
792
931
|
changed: result.changed,
|
|
793
932
|
inSync: result.inSync,
|
|
794
|
-
counts: counts
|
|
933
|
+
counts: totals.counts,
|
|
795
934
|
result,
|
|
796
935
|
});
|
|
797
936
|
}
|
|
798
937
|
|
|
799
938
|
/** 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 });
|
|
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 });
|
|
803
942
|
deps.emit('converge:end', {
|
|
804
943
|
trigger: options.trigger ?? 'converge',
|
|
805
944
|
success: false,
|
|
806
945
|
durationMs: Date.now() - startedAt,
|
|
807
946
|
changed: result.changed,
|
|
808
947
|
inSync: false,
|
|
809
|
-
counts: counts
|
|
948
|
+
counts: totals.counts,
|
|
810
949
|
error: errorText(error),
|
|
811
950
|
result,
|
|
812
951
|
});
|
|
@@ -817,11 +956,15 @@ async function reportFailure(deps, options, result, startedAt, error) {
|
|
|
817
956
|
* read → plan → guard → (per collection: re-plan → apply → verify) → report.
|
|
818
957
|
*
|
|
819
958
|
* `options`: `{ definitions, prune?, rebuildUnique?, dryRun?, trigger?, requestedBy?,
|
|
820
|
-
* reason? }` —
|
|
959
|
+
* reason?, search? }` —
|
|
821
960
|
* `definitions` normalized (collections.js); `prune` the default for
|
|
822
961
|
* definitions that do not set their own; `rebuildUnique` lets a rebuild drop a
|
|
823
962
|
* unique index it builds back (a conflict otherwise); `trigger` is
|
|
824
|
-
* `'converge'` or `'up'` (the after-up hook), for events and logs
|
|
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`.
|
|
825
968
|
*
|
|
826
969
|
* A plan with any conflict refuses the whole run before the first write. A
|
|
827
970
|
* failed step stops the run (`ConvergeFailedError`); an abort between steps
|
|
@@ -831,16 +974,26 @@ async function reportFailure(deps, options, result, startedAt, error) {
|
|
|
831
974
|
async function runConverge(deps, options, signal) {
|
|
832
975
|
const { definitions, dryRun = false, trigger = 'converge' } = options;
|
|
833
976
|
const startedAt = Date.now();
|
|
834
|
-
|
|
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;
|
|
835
984
|
const result = {
|
|
836
985
|
dryRun,
|
|
837
986
|
changed: 0,
|
|
838
987
|
inSync: true,
|
|
839
988
|
collections: plans.map(({ name, actions }) => ({ name, actions })),
|
|
840
989
|
};
|
|
841
|
-
if (dryRun) return reportPlan(deps, result);
|
|
990
|
+
if (dryRun) return reportPlan(deps, result, search);
|
|
842
991
|
|
|
843
|
-
const
|
|
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 };
|
|
844
997
|
deps.emit('converge:start', { trigger, collections: definitions.length });
|
|
845
998
|
try {
|
|
846
999
|
refuseConflicts(result);
|
|
@@ -853,15 +1006,19 @@ async function runConverge(deps, options, signal) {
|
|
|
853
1006
|
result.collections[position].actions = plan.actions;
|
|
854
1007
|
warnRenamed(deps, plan);
|
|
855
1008
|
if (plan.steps.length === 0) continue;
|
|
856
|
-
const kept = await applyCollection(deps, plan, result, signal);
|
|
857
|
-
await verifyFixedPoint(run, position, kept);
|
|
1009
|
+
const kept = await applyCollection(deps, plan, result, signal, search);
|
|
1010
|
+
await verifyFixedPoint(run, position, kept, signal);
|
|
858
1011
|
}
|
|
859
|
-
await
|
|
1012
|
+
await waitPhase(run, signal);
|
|
1013
|
+
await reportSuccess(deps, options, result, startedAt, search);
|
|
860
1014
|
return result;
|
|
861
1015
|
} catch (error) {
|
|
862
|
-
|
|
863
|
-
|
|
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);
|
|
864
1021
|
}
|
|
865
1022
|
}
|
|
866
1023
|
|
|
867
|
-
module.exports = {
|
|
1024
|
+
module.exports = { readLiveState, readLiveStates, runConverge };
|