@alexify/migronaut 2.0.0 → 2.1.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 +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -1
- package/package.json +21 -5
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +36 -2
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
const { ConvergeFailedError, MigronautError } = require('../errors/index.js');
|
|
2
|
+
const { pickActor } = require('../utils/actor.js');
|
|
3
|
+
const { mapLimit } = require('../utils/concurrency.js');
|
|
4
|
+
const { errorText } = require('../utils/error.js');
|
|
5
|
+
const { CHANGE_ACTIONS, isDestructive, planCollection, summarize } = require('./converge-plan.js');
|
|
6
|
+
const { inPlaceCapabilities } = require('./index-spec.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
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
|
|
12
|
+
* the plan out one operation at a time. The declaration is the only source of
|
|
13
|
+
* truth, and the database is checked against it afresh each time: the history
|
|
14
|
+
* a run appends (converge-log.js) is for people, and nothing reads it back to
|
|
15
|
+
* decide what to do.
|
|
16
|
+
*
|
|
17
|
+
* Pure orchestration over capabilities the MigratorKit injects (`deps`):
|
|
18
|
+
* `{db, logger, fields, emit, assertNotAborted}`, and optionally `audit` +
|
|
19
|
+
* `record` (the history entry) and `shardKeyOf` (behind a mongos).
|
|
20
|
+
*/
|
|
21
|
+
|
|
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
|
+
const NAMESPACE_EXISTS = 48;
|
|
43
|
+
|
|
44
|
+
/** What usually fixes the server error behind a failed step */
|
|
45
|
+
const HINTS = {
|
|
46
|
+
11000:
|
|
47
|
+
'existing documents hold duplicate values for this unique index — deduplicate them in a ' +
|
|
48
|
+
'migration first',
|
|
49
|
+
13: 'not authorized — collMod and creating a collection with a validator need the dbAdmin role',
|
|
50
|
+
85:
|
|
51
|
+
'an equivalent index already exists under another name — declare it under that name, or ' +
|
|
52
|
+
'converge with prune to replace it',
|
|
53
|
+
86: 'an index with this name already exists with a different key or options',
|
|
54
|
+
359:
|
|
55
|
+
'existing documents hold duplicate values for this key — deduplicate them in a migration ' +
|
|
56
|
+
'first (the index was left as it was)',
|
|
57
|
+
};
|
|
58
|
+
|
|
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
|
+
/** Shard key of each named collection, behind a mongos — when `config.collections` may be read */
|
|
85
|
+
async function readShardKeys(deps, names) {
|
|
86
|
+
const keys = new Map();
|
|
87
|
+
if (typeof deps.shardKeyOf !== 'function') return keys;
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
try {
|
|
90
|
+
const key = await deps.shardKeyOf(name);
|
|
91
|
+
if (key) keys.set(name, key);
|
|
92
|
+
} catch {
|
|
93
|
+
// Not readable (privileges): a refused drop is caught when it happens.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return keys;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The server's refusal to drop the index that backs a shard key */
|
|
100
|
+
function isShardKeyRefusal(error) {
|
|
101
|
+
return /shard key/i.test(errorText(error));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const VERBS = { create: 'create', modify: 'modify', recreate: 'rebuild', drop: 'drop' };
|
|
105
|
+
const LABELS = {
|
|
106
|
+
create: '✔ Created ',
|
|
107
|
+
modify: '✔ Modified',
|
|
108
|
+
recreate: '✔ Rebuilt ',
|
|
109
|
+
drop: '✔ Dropped ',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every named collection's live state, in order: one `listCollections` for
|
|
114
|
+
* all of them, then their `listIndexes` a few at a time — not two sequential
|
|
115
|
+
* round trips per collection, which for a few hundred declared collections
|
|
116
|
+
* is the better part of a minute before the first decision.
|
|
117
|
+
*/
|
|
118
|
+
async function readLiveStates(db, names) {
|
|
119
|
+
const infos = await db
|
|
120
|
+
.listCollections({ name: { $in: names } }, { nameOnly: false, ...READ_OPTIONS })
|
|
121
|
+
.toArray();
|
|
122
|
+
const byName = new Map();
|
|
123
|
+
for (const info of infos) byName.set(info.name, info);
|
|
124
|
+
return mapLimit(names, READ_CONCURRENCY, (name) => liveStateOf(db, name, byName.get(name)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One collection's live state: `{ exists, type?, options?, indexes }`. A view
|
|
129
|
+
* or a time-series collection is reported by type (the planner refuses it)
|
|
130
|
+
* without listing indexes.
|
|
131
|
+
*/
|
|
132
|
+
async function readLiveState(db, name) {
|
|
133
|
+
const [state] = await readLiveStates(db, [name]);
|
|
134
|
+
return state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function liveStateOf(db, name, info) {
|
|
138
|
+
if (!info) return { exists: false, indexes: [] };
|
|
139
|
+
const options = info.options ?? {};
|
|
140
|
+
if (info.type !== undefined && info.type !== 'collection') {
|
|
141
|
+
return { exists: true, type: info.type, options, indexes: [] };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const indexes = await db.collection(name).listIndexes(READ_OPTIONS).toArray();
|
|
145
|
+
return { exists: true, type: 'collection', options, indexes };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Dropped between the two reads — plan it as missing, like the first read would have.
|
|
148
|
+
if (error?.code === NAMESPACE_NOT_FOUND) return { exists: false, indexes: [] };
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Copy-on-write, like the kit's #attachResults: the error may live on a shared abort signal */
|
|
154
|
+
function attachConverge(error, result) {
|
|
155
|
+
if (error instanceof MigronautError) {
|
|
156
|
+
error.context = { ...error.context, converge: result };
|
|
157
|
+
}
|
|
158
|
+
return error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function describe(action) {
|
|
162
|
+
if (action.target === 'index') return `index "${action.name}"`;
|
|
163
|
+
return action.target === 'validator' ? 'the validator' : 'the collection';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What a failed step was doing — one row, or the indexes one command built together */
|
|
167
|
+
function describeAll(actions) {
|
|
168
|
+
if (actions.length === 1) return describe(actions[0]);
|
|
169
|
+
return `indexes ${actions.map((action) => `"${action.name}"`).join(', ')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Errors that leave it unknown what the server did: the connection broke, or
|
|
174
|
+
* a client-side deadline ran out, while the server may well still be building.
|
|
175
|
+
* After one of these, nothing is "put back" — a restore would race a build the
|
|
176
|
+
* server is still running; the next converge reads what actually happened.
|
|
177
|
+
*/
|
|
178
|
+
const CONNECTION_ERROR_NAMES = new Set([
|
|
179
|
+
'MongoNetworkError',
|
|
180
|
+
'MongoNetworkTimeoutError',
|
|
181
|
+
'MongoServerSelectionError',
|
|
182
|
+
'MongoOperationTimeoutError',
|
|
183
|
+
'MongoTopologyClosedError',
|
|
184
|
+
]);
|
|
185
|
+
const CONNECTION_ERROR_CODES = new Set([50, 89, 91, 189, 10107, 11600, 11602, 13435]);
|
|
186
|
+
|
|
187
|
+
function isConnectionTrouble(error) {
|
|
188
|
+
return CONNECTION_ERROR_NAMES.has(error?.name) || CONNECTION_ERROR_CODES.has(error?.code);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What a failed rebuild left behind, when it could not put the dropped index back */
|
|
192
|
+
function lostIndexes(extra) {
|
|
193
|
+
if (extra.restored !== false) return '';
|
|
194
|
+
if (extra.uncertain) {
|
|
195
|
+
return (
|
|
196
|
+
` — the connection failed mid-build, so ${extra.dropped.map((name) => `"${name}"`).join(', ')} ` +
|
|
197
|
+
'was not put back: the server may still be building; converge --dry-run shows what it finished'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return (
|
|
201
|
+
` — the dropped index(es) ${extra.dropped.map((name) => `"${name}"`).join(', ')} could ` +
|
|
202
|
+
`not be put back (${extra.restoreError}); the collection is without them until fixed`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function wrapFailure(error, collection, actions, result, extra = {}) {
|
|
207
|
+
const action = actions[0];
|
|
208
|
+
if (error instanceof MigronautError) return attachConverge(error, result);
|
|
209
|
+
const mongoCode = typeof error?.code === 'number' ? error.code : undefined;
|
|
210
|
+
const hint = mongoCode !== undefined ? HINTS[mongoCode] : undefined;
|
|
211
|
+
const cause = errorText(error);
|
|
212
|
+
return new ConvergeFailedError(
|
|
213
|
+
`Could not ${VERBS[action.action] ?? action.action} ${describeAll(actions)} on ${collection}: ` +
|
|
214
|
+
`${cause}${hint ? ` — ${hint}` : ''}${lostIndexes(extra)}`,
|
|
215
|
+
{
|
|
216
|
+
phase: 'apply',
|
|
217
|
+
collection,
|
|
218
|
+
target: action.target,
|
|
219
|
+
name: action.name,
|
|
220
|
+
action: action.action,
|
|
221
|
+
cause,
|
|
222
|
+
...(mongoCode !== undefined ? { mongoCode } : {}),
|
|
223
|
+
...(hint ? { hint } : {}),
|
|
224
|
+
...extra,
|
|
225
|
+
converge: result,
|
|
226
|
+
},
|
|
227
|
+
{ cause: error },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function dropIndex(db, collection, name) {
|
|
232
|
+
try {
|
|
233
|
+
await db.collection(collection).dropIndex(name);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// Already gone is the state this step wanted.
|
|
236
|
+
if (error?.code !== INDEX_NOT_FOUND) throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Drop, then create — the only way to change most index options. A failed
|
|
242
|
+
* create (a unique index over duplicate data, typically) would otherwise
|
|
243
|
+
* leave the collection without the index it had a moment ago, so every
|
|
244
|
+
* dropped index whose name is still free is put back, best-effort, and the
|
|
245
|
+
* failure says whether that worked.
|
|
246
|
+
*
|
|
247
|
+
* Returns undefined on success, or `{ error, action, extra }` for the create
|
|
248
|
+
* that failed — a drop that fails has nothing to restore and simply throws.
|
|
249
|
+
*/
|
|
250
|
+
async function runRebuild(db, collection, step, settle) {
|
|
251
|
+
const dropped = [];
|
|
252
|
+
for (const drop of step.drops) {
|
|
253
|
+
await dropIndex(db, collection, drop.name);
|
|
254
|
+
dropped.push(drop);
|
|
255
|
+
}
|
|
256
|
+
const created = new Set();
|
|
257
|
+
for (const { spec, action } of step.creates) {
|
|
258
|
+
const startedAt = Date.now();
|
|
259
|
+
try {
|
|
260
|
+
await db.collection(collection).createIndexes([spec]);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const droppedNames = dropped.map((drop) => drop.name);
|
|
263
|
+
if (isConnectionTrouble(error)) {
|
|
264
|
+
return {
|
|
265
|
+
error,
|
|
266
|
+
actions: [action],
|
|
267
|
+
extra: { restored: false, uncertain: true, dropped: droppedNames },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const restoreErrors = [];
|
|
271
|
+
for (const drop of dropped) {
|
|
272
|
+
if (created.has(drop.name)) continue;
|
|
273
|
+
try {
|
|
274
|
+
await db.collection(collection).createIndexes([drop.restore]);
|
|
275
|
+
} catch (restoreError) {
|
|
276
|
+
restoreErrors.push(`${drop.name}: ${errorText(restoreError)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
error,
|
|
281
|
+
actions: [action],
|
|
282
|
+
extra: {
|
|
283
|
+
restored: restoreErrors.length === 0,
|
|
284
|
+
dropped: droppedNames,
|
|
285
|
+
...(restoreErrors.length > 0 ? { restoreError: restoreErrors.join('; ') } : {}),
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
created.add(spec.name);
|
|
290
|
+
settle(action, Date.now() - startedAt);
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Make an index unique in place (MongoDB 7.0+): `prepareUnique` first — from
|
|
297
|
+
* then on no new duplicate can be written — then `unique`, which checks the
|
|
298
|
+
* existing documents. If they hold duplicates, `prepareUnique` is taken back
|
|
299
|
+
* off, so the index is exactly as it was before. Never a window without the
|
|
300
|
+
* index, and no rebuild of a large collection.
|
|
301
|
+
*/
|
|
302
|
+
async function convertToUnique(db, collection, step) {
|
|
303
|
+
await db.command({ collMod: collection, index: { name: step.name, prepareUnique: true } });
|
|
304
|
+
try {
|
|
305
|
+
await db.command({ collMod: collection, index: { name: step.name, unique: true } });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
await db
|
|
308
|
+
.command({ collMod: collection, index: { name: step.name, prepareUnique: false } })
|
|
309
|
+
.catch(() => undefined);
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
if (Object.keys(step.rest).length > 0) {
|
|
313
|
+
await db.command({ collMod: collection, index: { name: step.name, ...step.rest } });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Carry out one planned step; returns `{ error, actions, extra }` on failure, else undefined */
|
|
318
|
+
async function runStep(db, collection, step, settle) {
|
|
319
|
+
try {
|
|
320
|
+
if (step.op === 'rebuild') return await runRebuild(db, collection, step, settle);
|
|
321
|
+
const startedAt = Date.now();
|
|
322
|
+
if (step.op === 'createCollection') {
|
|
323
|
+
try {
|
|
324
|
+
await db.createCollection(collection, step.options);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// Created by someone else since it was read — set the validator on it instead.
|
|
327
|
+
if (error?.code !== NAMESPACE_EXISTS) throw error;
|
|
328
|
+
if (step.options.validator) await db.command({ collMod: collection, ...step.options });
|
|
329
|
+
}
|
|
330
|
+
} else if (step.op === 'collMod') {
|
|
331
|
+
await db.command({ collMod: collection, ...step.command });
|
|
332
|
+
} else if (step.op === 'convertUnique') {
|
|
333
|
+
await convertToUnique(db, collection, step);
|
|
334
|
+
} else if (step.op === 'createIndexes') {
|
|
335
|
+
// One command, one pass over the collection for all of them — and all
|
|
336
|
+
// or nothing: a build that fails leaves none of the batch behind.
|
|
337
|
+
await db.collection(collection).createIndexes(step.specs);
|
|
338
|
+
} else {
|
|
339
|
+
try {
|
|
340
|
+
await dropIndex(db, collection, step.name);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// A sharded collection's shard-key index cannot be dropped, and its
|
|
343
|
+
// shard key could not be read up front: keep it, say so, go on.
|
|
344
|
+
if (!isShardKeyRefusal(error)) throw error;
|
|
345
|
+
for (const action of step.actions) {
|
|
346
|
+
action.action = 'keep';
|
|
347
|
+
action.reason = 'backs the shard key';
|
|
348
|
+
}
|
|
349
|
+
return { kept: true };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const durationMs = Date.now() - startedAt;
|
|
353
|
+
for (const action of step.actions) settle(action, durationMs);
|
|
354
|
+
return undefined;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return {
|
|
357
|
+
error,
|
|
358
|
+
actions: step.op === 'createIndexes' ? step.actions : [step.actions[0]],
|
|
359
|
+
extra: {},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Mark every row that never ran, once a run stops early */
|
|
365
|
+
function settleRest(result) {
|
|
366
|
+
for (const collection of result.collections) {
|
|
367
|
+
for (const action of collection.actions) {
|
|
368
|
+
if (action.status !== 'planned') continue;
|
|
369
|
+
action.status = action.action === 'conflict' ? 'failed' : 'skipped';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function finalize(result) {
|
|
375
|
+
const { changes, applied, conflicts } = summarize(result.collections);
|
|
376
|
+
if (result.dryRun) {
|
|
377
|
+
result.changed = changes;
|
|
378
|
+
result.inSync = changes === 0 && conflicts === 0;
|
|
379
|
+
} else {
|
|
380
|
+
result.changed = applied;
|
|
381
|
+
result.inSync = conflicts === 0 && applied === changes && !(result.unstable?.length > 0);
|
|
382
|
+
}
|
|
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;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Steps that build an index — the ones that can take minutes or hours */
|
|
401
|
+
const BUILD_STEPS = new Set(['createIndexes', 'rebuild']);
|
|
402
|
+
const STARTING = {
|
|
403
|
+
create: 'Creating',
|
|
404
|
+
modify: 'Modifying',
|
|
405
|
+
recreate: 'Rebuilding',
|
|
406
|
+
drop: 'Dropping',
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Say a step is starting, before it runs: the `converge:action` event with
|
|
411
|
+
* status `'started'` for every step, and a log line for an index build — on a
|
|
412
|
+
* large collection it is the only sign of which index the run is busy with.
|
|
413
|
+
*/
|
|
414
|
+
function announce(deps, collection, step) {
|
|
415
|
+
for (const action of step.actions) {
|
|
416
|
+
deps.emit('converge:action', {
|
|
417
|
+
collection,
|
|
418
|
+
target: action.target,
|
|
419
|
+
name: action.name,
|
|
420
|
+
action: action.action,
|
|
421
|
+
status: 'started',
|
|
422
|
+
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
423
|
+
});
|
|
424
|
+
const what = action.target === 'index' ? `${action.name} on ${collection}` : collection;
|
|
425
|
+
const line = `… ${STARTING[action.action] ?? action.action} ${action.target} ${what}`;
|
|
426
|
+
const fields = deps.fields({
|
|
427
|
+
collection,
|
|
428
|
+
target: action.target,
|
|
429
|
+
name: action.name,
|
|
430
|
+
action: action.action,
|
|
431
|
+
status: 'started',
|
|
432
|
+
});
|
|
433
|
+
if (BUILD_STEPS.has(step.op)) deps.logger.info(line, fields);
|
|
434
|
+
else deps.logger.debug(line, fields);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
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
|
+
/**
|
|
457
|
+
* Append the run to the converge history — a run that changed something or
|
|
458
|
+
* failed; a converge that found everything in place is not news. Best-effort,
|
|
459
|
+
* like the changelog's failure trace: a history that cannot be written is
|
|
460
|
+
* warned about, never allowed to turn a converge that worked into a failure.
|
|
461
|
+
*/
|
|
462
|
+
async function recordHistory(deps, options, result, { startedAt, error }) {
|
|
463
|
+
if (typeof deps.record !== 'function') return;
|
|
464
|
+
if (error === undefined && result.changed === 0) return;
|
|
465
|
+
const finishedAt = new Date();
|
|
466
|
+
try {
|
|
467
|
+
await deps.record({
|
|
468
|
+
...deps.audit(),
|
|
469
|
+
trigger: options.trigger ?? 'converge',
|
|
470
|
+
startedAt: new Date(startedAt),
|
|
471
|
+
finishedAt,
|
|
472
|
+
durationMs: finishedAt.getTime() - startedAt,
|
|
473
|
+
success: error === undefined,
|
|
474
|
+
...(error !== undefined ? { error: errorText(error) } : {}),
|
|
475
|
+
...pickActor(options),
|
|
476
|
+
changed: result.changed,
|
|
477
|
+
actions: historyActions(result),
|
|
478
|
+
...(result.unstable ? { unstable: result.unstable } : {}),
|
|
479
|
+
});
|
|
480
|
+
} catch (recordError) {
|
|
481
|
+
deps.logger.warn(
|
|
482
|
+
`⚠ Could not record the converge in its history: ${errorText(recordError)}`,
|
|
483
|
+
deps.fields({ error: errorText(recordError) }),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
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
|
+
/**
|
|
512
|
+
* The read and plan phases: every declared collection's live state (one
|
|
513
|
+
* read for all of them), and its plan. Behind a mongos each live state also
|
|
514
|
+
* carries the collection's shard key, so the plan keeps the index behind it.
|
|
515
|
+
*
|
|
516
|
+
* Returns `{ planFor, live, plans }` — `planFor(definition, live)` plans one
|
|
517
|
+
* collection with the run's options, for the re-plans and the fixed-point
|
|
518
|
+
* check to come.
|
|
519
|
+
*/
|
|
520
|
+
async function readAndPlan(deps, options) {
|
|
521
|
+
const { db } = deps;
|
|
522
|
+
const { definitions } = options;
|
|
523
|
+
const pruneFor = (definition) => definition.prune ?? options.prune ?? false;
|
|
524
|
+
const server = await readServer(db);
|
|
525
|
+
const capabilities = inPlaceCapabilities(server.version);
|
|
526
|
+
const planFor = (definition, live) =>
|
|
527
|
+
planCollection(definition, live, {
|
|
528
|
+
prune: pruneFor(definition),
|
|
529
|
+
rebuildUnique: options.rebuildUnique === true,
|
|
530
|
+
capabilities,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const names = definitions.map((definition) => definition.name);
|
|
534
|
+
const live = await readLiveStates(db, names);
|
|
535
|
+
if (server.mongos) {
|
|
536
|
+
const shardKeys = await readShardKeys(deps, names);
|
|
537
|
+
for (const [position, name] of names.entries()) {
|
|
538
|
+
if (shardKeys.has(name)) live[position].shardKey = shardKeys.get(name);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
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.
|
|
544
|
+
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
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { planFor, live, plans };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A dry run's answer: the plan as the result, and one line about it */
|
|
558
|
+
function reportPlan(deps, result) {
|
|
559
|
+
finalize(result);
|
|
560
|
+
const total = result.collections.length;
|
|
561
|
+
const line =
|
|
562
|
+
`◎ Planned ${result.changed} change(s) in ${touched(result)} of ${total} ` + 'collection(s)';
|
|
563
|
+
const fields = deps.fields({ dryRun: true, changed: result.changed, collections: total });
|
|
564
|
+
// A probe that finds nothing to do (a scheduler tick, a CI gate) is not news.
|
|
565
|
+
if (result.inSync) deps.logger.debug(line, fields);
|
|
566
|
+
else deps.logger.info(line, fields);
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The guard phase: a plan with any conflict refuses the whole run, before the first write */
|
|
571
|
+
function refuseConflicts(result) {
|
|
572
|
+
const conflicts = [];
|
|
573
|
+
for (const collection of result.collections) {
|
|
574
|
+
for (const action of collection.actions) {
|
|
575
|
+
if (action.action !== 'conflict') continue;
|
|
576
|
+
conflicts.push({
|
|
577
|
+
collection: collection.name,
|
|
578
|
+
target: action.target,
|
|
579
|
+
name: action.name,
|
|
580
|
+
reason: action.reason,
|
|
581
|
+
...(action.liveName !== undefined ? { liveName: action.liveName } : {}),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (conflicts.length === 0) return;
|
|
586
|
+
settleRest(result);
|
|
587
|
+
throw new ConvergeFailedError(
|
|
588
|
+
`Converge refused: ${conflicts.length} conflict(s) — ` +
|
|
589
|
+
conflicts
|
|
590
|
+
.map((conflict) =>
|
|
591
|
+
conflict.target === 'collection'
|
|
592
|
+
? `${conflict.collection} ${conflict.reason}`
|
|
593
|
+
: `${conflict.collection} ${describe(conflict)}: ${conflict.reason}`,
|
|
594
|
+
)
|
|
595
|
+
.join('; '),
|
|
596
|
+
{ phase: 'plan', conflicts, converge: result },
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The collection at `position`, planned afresh — the guard again, right
|
|
602
|
+
* before its turn. It may only have become *less* to do: a conflict or a
|
|
603
|
+
* drop/rebuild that the plan the run started from did not have — an index
|
|
604
|
+
* someone created meanwhile — refuses the run here, before this collection is
|
|
605
|
+
* touched, rather than act on what nobody reviewed.
|
|
606
|
+
*/
|
|
607
|
+
async function replan(run, position) {
|
|
608
|
+
const { deps, definitions, live, plans, result, planFor } = run;
|
|
609
|
+
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
|
+
);
|
|
616
|
+
const introduced = plan.actions.filter(
|
|
617
|
+
(action) =>
|
|
618
|
+
(action.action === 'conflict' || isDestructive(action)) &&
|
|
619
|
+
!known.has(`${action.target}:${action.name}:${action.action}`),
|
|
620
|
+
);
|
|
621
|
+
if (introduced.length === 0) return plan;
|
|
622
|
+
result.collections[position].actions = plan.actions;
|
|
623
|
+
settleRest(result);
|
|
624
|
+
throw new ConvergeFailedError(
|
|
625
|
+
`Converge stopped before ${definition.name}: it changed while the run was under way — ` +
|
|
626
|
+
introduced.map((action) => `${describe(action)} now ${action.action}`).join('; '),
|
|
627
|
+
{
|
|
628
|
+
phase: 'replan',
|
|
629
|
+
collection: definition.name,
|
|
630
|
+
introduced: introduced.map(({ target, name, action, reason }) => ({
|
|
631
|
+
target,
|
|
632
|
+
name,
|
|
633
|
+
action,
|
|
634
|
+
...(reason !== undefined ? { reason } : {}),
|
|
635
|
+
})),
|
|
636
|
+
converge: result,
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** A declared index found under another name is kept as it is — say so */
|
|
642
|
+
function warnRenamed(deps, plan) {
|
|
643
|
+
for (const action of plan.actions) {
|
|
644
|
+
if (action.liveName !== undefined && action.action === 'unchanged') {
|
|
645
|
+
deps.logger.warn(
|
|
646
|
+
`⚠ ${plan.name}: index "${action.name}" exists as "${action.liveName}" — kept under ` +
|
|
647
|
+
'its current name',
|
|
648
|
+
deps.fields({ collection: plan.name, index: action.name, liveName: action.liveName }),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Mark a row applied, and say so: the `converge:action` event and its log line */
|
|
655
|
+
function settler(deps, collection) {
|
|
656
|
+
return (action, durationMs) => {
|
|
657
|
+
action.status = 'applied';
|
|
658
|
+
action.durationMs = durationMs;
|
|
659
|
+
deps.emit('converge:action', {
|
|
660
|
+
collection,
|
|
661
|
+
target: action.target,
|
|
662
|
+
name: action.name,
|
|
663
|
+
action: action.action,
|
|
664
|
+
status: 'applied',
|
|
665
|
+
durationMs,
|
|
666
|
+
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
667
|
+
});
|
|
668
|
+
const what = action.target === 'index' ? `${action.name} on ${collection}` : collection;
|
|
669
|
+
deps.logger.info(
|
|
670
|
+
`${LABELS[action.action]} ${action.target} ${what} [${durationMs}ms]`,
|
|
671
|
+
deps.fields({
|
|
672
|
+
collection,
|
|
673
|
+
target: action.target,
|
|
674
|
+
name: action.name,
|
|
675
|
+
action: action.action,
|
|
676
|
+
durationMs,
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The apply phase for one collection: its steps, in order. Returns the
|
|
684
|
+
* indexes the server would not let go of (a shard key's), which the verify
|
|
685
|
+
* phase must not report as unstable. A failed step stops the run.
|
|
686
|
+
*/
|
|
687
|
+
async function applyCollection(deps, plan, result, signal) {
|
|
688
|
+
const kept = new Set();
|
|
689
|
+
const settle = settler(deps, plan.name);
|
|
690
|
+
for (const step of plan.steps) {
|
|
691
|
+
// Between operations is the only safe place to stop — and never inside
|
|
692
|
+
// 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
|
+
}
|
|
699
|
+
announce(deps, plan.name, step);
|
|
700
|
+
const failure = await runStep(deps.db, plan.name, step, settle);
|
|
701
|
+
if (failure?.kept) {
|
|
702
|
+
for (const action of step.actions) {
|
|
703
|
+
kept.add(action.name);
|
|
704
|
+
deps.logger.warn(
|
|
705
|
+
`⚠ ${plan.name}: index "${action.name}" backs the shard key — kept, not dropped`,
|
|
706
|
+
deps.fields({ collection: plan.name, index: action.name }),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (failure) {
|
|
712
|
+
const { error, actions, extra } = failure;
|
|
713
|
+
for (const action of actions) {
|
|
714
|
+
action.status = 'failed';
|
|
715
|
+
deps.emit('converge:action', {
|
|
716
|
+
collection: plan.name,
|
|
717
|
+
target: action.target,
|
|
718
|
+
name: action.name,
|
|
719
|
+
action: action.action,
|
|
720
|
+
status: 'failed',
|
|
721
|
+
error: errorText(error),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
settleRest(result);
|
|
725
|
+
throw wrapFailure(error, plan.name, actions, result, extra);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return kept;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The verify phase — the fixed-point check: what was just applied must now
|
|
733
|
+
* compare as unchanged. Anything that does not would be "changed" again on
|
|
734
|
+
* every run — a comparison rule that disagrees with this server version — so
|
|
735
|
+
* it is reported in `result.unstable` instead of silently rebuilt forever.
|
|
736
|
+
*/
|
|
737
|
+
async function verifyFixedPoint(run, position, kept) {
|
|
738
|
+
const { deps, definitions, live, result, planFor } = run;
|
|
739
|
+
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);
|
|
743
|
+
for (const action of after.actions) {
|
|
744
|
+
if (!CHANGE_ACTIONS.has(action.action)) continue;
|
|
745
|
+
if (action.action === 'drop' && kept.has(action.name)) continue;
|
|
746
|
+
(result.unstable ??= []).push({
|
|
747
|
+
collection: definition.name,
|
|
748
|
+
target: action.target,
|
|
749
|
+
name: action.name,
|
|
750
|
+
action: action.action,
|
|
751
|
+
...(action.reason !== undefined ? { reason: action.reason } : {}),
|
|
752
|
+
});
|
|
753
|
+
deps.logger.warn(
|
|
754
|
+
`⚠ ${definition.name}: ${describe(action)} still differs after converge` +
|
|
755
|
+
`${action.reason ? ` (${action.reason})` : ''} — it would change again on every run`,
|
|
756
|
+
deps.fields({ collection: definition.name, target: action.target, name: action.name }),
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** A converge that ran to the end: its closing lines, its history entry, `converge:end` */
|
|
762
|
+
async function reportSuccess(deps, options, result, startedAt) {
|
|
763
|
+
const { logger } = deps;
|
|
764
|
+
const total = result.collections.length;
|
|
765
|
+
settleRest(result);
|
|
766
|
+
finalize(result);
|
|
767
|
+
const durationMs = Date.now() - startedAt;
|
|
768
|
+
const kept = undeclaredKept(result);
|
|
769
|
+
if (result.changed > 0) {
|
|
770
|
+
logger.info(
|
|
771
|
+
`✔ Converged ${result.changed} change(s) in ${touched(result)} of ${total} ` +
|
|
772
|
+
`collection(s) in ${durationMs}ms`,
|
|
773
|
+
deps.fields({ changed: result.changed, collections: total, durationMs }),
|
|
774
|
+
);
|
|
775
|
+
} else {
|
|
776
|
+
logger.info(
|
|
777
|
+
'Collections already match their declarations',
|
|
778
|
+
deps.fields({ collections: total, durationMs }),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (kept > 0) {
|
|
782
|
+
logger.info(
|
|
783
|
+
`• Kept ${kept} undeclared index(es) — converge with prune to drop them`,
|
|
784
|
+
deps.fields({ kept }),
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
await recordHistory(deps, options, result, { startedAt });
|
|
788
|
+
deps.emit('converge:end', {
|
|
789
|
+
trigger: options.trigger ?? 'converge',
|
|
790
|
+
success: true,
|
|
791
|
+
durationMs,
|
|
792
|
+
changed: result.changed,
|
|
793
|
+
inSync: result.inSync,
|
|
794
|
+
counts: counts(result),
|
|
795
|
+
result,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** 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 });
|
|
803
|
+
deps.emit('converge:end', {
|
|
804
|
+
trigger: options.trigger ?? 'converge',
|
|
805
|
+
success: false,
|
|
806
|
+
durationMs: Date.now() - startedAt,
|
|
807
|
+
changed: result.changed,
|
|
808
|
+
inSync: false,
|
|
809
|
+
counts: counts(result),
|
|
810
|
+
error: errorText(error),
|
|
811
|
+
result,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Plan every declared collection and, unless `dryRun`, carry the plans out:
|
|
817
|
+
* read → plan → guard → (per collection: re-plan → apply → verify) → report.
|
|
818
|
+
*
|
|
819
|
+
* `options`: `{ definitions, prune?, rebuildUnique?, dryRun?, trigger?, requestedBy?,
|
|
820
|
+
* reason? }` —
|
|
821
|
+
* `definitions` normalized (collections.js); `prune` the default for
|
|
822
|
+
* definitions that do not set their own; `rebuildUnique` lets a rebuild drop a
|
|
823
|
+
* unique index it builds back (a conflict otherwise); `trigger` is
|
|
824
|
+
* `'converge'` or `'up'` (the after-up hook), for events and logs.
|
|
825
|
+
*
|
|
826
|
+
* A plan with any conflict refuses the whole run before the first write. A
|
|
827
|
+
* failed step stops the run (`ConvergeFailedError`); an abort between steps
|
|
828
|
+
* stops it with the abort's own error. Either way `context.converge` holds
|
|
829
|
+
* the result so far.
|
|
830
|
+
*/
|
|
831
|
+
async function runConverge(deps, options, signal) {
|
|
832
|
+
const { definitions, dryRun = false, trigger = 'converge' } = options;
|
|
833
|
+
const startedAt = Date.now();
|
|
834
|
+
const { planFor, live, plans } = await readAndPlan(deps, options);
|
|
835
|
+
const result = {
|
|
836
|
+
dryRun,
|
|
837
|
+
changed: 0,
|
|
838
|
+
inSync: true,
|
|
839
|
+
collections: plans.map(({ name, actions }) => ({ name, actions })),
|
|
840
|
+
};
|
|
841
|
+
if (dryRun) return reportPlan(deps, result);
|
|
842
|
+
|
|
843
|
+
const run = { deps, definitions, live, plans, result, planFor };
|
|
844
|
+
deps.emit('converge:start', { trigger, collections: definitions.length });
|
|
845
|
+
try {
|
|
846
|
+
refuseConflicts(result);
|
|
847
|
+
for (const position of plans.keys()) {
|
|
848
|
+
// Every collection but the first is re-read and re-planned right before
|
|
849
|
+
// its turn: the ones before it may have built indexes for hours, and a
|
|
850
|
+
// plan made at the start would act on a database that has moved on.
|
|
851
|
+
const plan = position === 0 ? plans[0] : await replan(run, position);
|
|
852
|
+
plans[position] = plan;
|
|
853
|
+
result.collections[position].actions = plan.actions;
|
|
854
|
+
warnRenamed(deps, plan);
|
|
855
|
+
if (plan.steps.length === 0) continue;
|
|
856
|
+
const kept = await applyCollection(deps, plan, result, signal);
|
|
857
|
+
await verifyFixedPoint(run, position, kept);
|
|
858
|
+
}
|
|
859
|
+
await reportSuccess(deps, options, result, startedAt);
|
|
860
|
+
return result;
|
|
861
|
+
} catch (error) {
|
|
862
|
+
await reportFailure(deps, options, result, startedAt, error);
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
module.exports = { READ_OPTIONS, readLiveState, readLiveStates, runConverge };
|