@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.
- package/CHANGELOG.md +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +605 -0
- package/src/core/background.js +1121 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- package/src/utils/error.js +11 -2
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +125 -1
- package/src/utils/template.js +69 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/src/core/converge.js
CHANGED
|
@@ -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 {
|
|
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
|
|
10
|
-
* declared state. Stateless — every run reads
|
|
11
|
-
* `
|
|
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)
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
633
|
+
plans[position] = planFor(definition, live[position]);
|
|
634
|
+
reviewPlan(deps, definition, plans[position], pruneFor(definition), ignored);
|
|
553
635
|
}
|
|
554
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
752
|
+
const { definitions, plans, result, planFor } = run;
|
|
609
753
|
const definition = definitions[position];
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
936
|
+
async function verifyFixedPoint(run, position, kept, signal) {
|
|
937
|
+
const { deps, definitions, result, planFor } = run;
|
|
739
938
|
const definition = definitions[position];
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
766
|
-
finalize(result);
|
|
994
|
+
const totals = finalize(result, search, true);
|
|
767
995
|
const durationMs = Date.now() - startedAt;
|
|
768
|
-
const kept =
|
|
996
|
+
const { kept } = totals;
|
|
769
997
|
if (result.changed > 0) {
|
|
770
998
|
logger.info(
|
|
771
|
-
`✔ Converged ${result.changed} change(s) in ${touched
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1100
|
+
await waitPhase(run, signal);
|
|
1101
|
+
await reportSuccess(deps, options, result, startedAt, search);
|
|
860
1102
|
return result;
|
|
861
1103
|
} catch (error) {
|
|
862
|
-
|
|
863
|
-
|
|
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 = {
|
|
1112
|
+
module.exports = { readLiveState, readLiveStates, runConverge };
|