@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/cli/table.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { TARGET_LABELS, isDestructive } = require('../core/converge-plan.js');
|
|
2
|
+
const { searchBuildState } = require('../core/search-index-spec.js');
|
|
1
3
|
const { createColors, stripAnsi } = require('../utils/colors.js');
|
|
2
4
|
const { formatDateTime } = require('../utils/date.js');
|
|
3
5
|
// Shared with the logger and spinner — cell values come from the changelog and
|
|
@@ -270,12 +272,43 @@ function convergeActionCell(colors, action) {
|
|
|
270
272
|
}
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
/**
|
|
275
|
+
/** Where the server is with a search index, when it is not simply serving it */
|
|
276
|
+
function searchBuildDetail(build) {
|
|
277
|
+
if (build === undefined) return '';
|
|
278
|
+
switch (searchBuildState(build)) {
|
|
279
|
+
case 'serving':
|
|
280
|
+
return '';
|
|
281
|
+
case 'failed':
|
|
282
|
+
return `FAILED${build.message ? `: ${build.message}` : ''}`;
|
|
283
|
+
case 'updating':
|
|
284
|
+
return 'updating';
|
|
285
|
+
case 'stale':
|
|
286
|
+
return 'STALE — not replicating';
|
|
287
|
+
default:
|
|
288
|
+
return build.status;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Whether a search index row is one worth showing even when nothing changes: not serving yet */
|
|
293
|
+
function searchNotServing(action) {
|
|
294
|
+
return action.target === 'searchIndex' && searchBuildDetail(action.build) !== '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The detail column: what differs, or why the row is what it is — and a search index's build */
|
|
274
298
|
function convergeDetail(action) {
|
|
275
299
|
if (action.reason === 'name' && action.liveName !== undefined) {
|
|
276
300
|
return `renamed from "${action.liveName}"`;
|
|
277
301
|
}
|
|
278
|
-
return action.reason ?? '';
|
|
302
|
+
if (action.target !== 'searchIndex') return action.reason ?? '';
|
|
303
|
+
const parts = [];
|
|
304
|
+
const type = action.to?.type ?? action.from?.type;
|
|
305
|
+
if (type === 'vectorSearch' && (action.action === 'create' || action.action === 'modify')) {
|
|
306
|
+
parts.push('vectorSearch');
|
|
307
|
+
}
|
|
308
|
+
if (action.reason) parts.push(action.reason);
|
|
309
|
+
const build = searchBuildDetail(action.build);
|
|
310
|
+
if (build) parts.push(build);
|
|
311
|
+
return parts.join(' · ');
|
|
279
312
|
}
|
|
280
313
|
|
|
281
314
|
/**
|
|
@@ -286,22 +319,32 @@ function convergeDetail(action) {
|
|
|
286
319
|
function renderConvergeTable(result, { all = false } = {}) {
|
|
287
320
|
const colors = palette();
|
|
288
321
|
const cells = [];
|
|
289
|
-
const counts = {
|
|
322
|
+
const counts = {
|
|
323
|
+
change: 0,
|
|
324
|
+
destructive: 0,
|
|
325
|
+
conflict: 0,
|
|
326
|
+
keep: 0,
|
|
327
|
+
searchKeep: 0,
|
|
328
|
+
skip: 0,
|
|
329
|
+
unchanged: 0,
|
|
330
|
+
touched: 0,
|
|
331
|
+
};
|
|
290
332
|
for (const collection of result.collections) {
|
|
291
333
|
let changes = 0;
|
|
292
334
|
for (const action of collection.actions) {
|
|
293
335
|
if (action.action === 'unchanged') counts.unchanged += 1;
|
|
336
|
+
else if (action.action === 'keep' && action.target === 'searchIndex') counts.searchKeep += 1;
|
|
294
337
|
else if (action.action === 'keep') counts.keep += 1;
|
|
295
338
|
else if (action.action === 'conflict') counts.conflict += 1;
|
|
339
|
+
else if (action.action === 'skip') counts.skip += 1;
|
|
296
340
|
else changes += 1;
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (action.action === 'unchanged' && !all) continue;
|
|
341
|
+
if (isDestructive(action)) counts.destructive += 1;
|
|
342
|
+
if (action.action === 'unchanged' && !all && !searchNotServing(action)) continue;
|
|
343
|
+
const named = action.target === 'index' || action.target === 'searchIndex';
|
|
301
344
|
cells.push([
|
|
302
345
|
truncate(sanitize(collection.name), MAX_NAME_WIDTH),
|
|
303
|
-
action.target,
|
|
304
|
-
|
|
346
|
+
TARGET_LABELS[action.target] ?? action.target,
|
|
347
|
+
named ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
|
|
305
348
|
convergeActionCell(colors, action.action),
|
|
306
349
|
truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
|
|
307
350
|
]);
|
|
@@ -323,6 +366,22 @@ function renderConvergeTable(result, { all = false } = {}) {
|
|
|
323
366
|
if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
|
|
324
367
|
if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
|
|
325
368
|
if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
|
|
369
|
+
if (counts.searchKeep > 0) parts.push(`${counts.searchKeep} undeclared search index(es) kept`);
|
|
370
|
+
if (counts.skip > 0) {
|
|
371
|
+
parts.push(colors.yellow(`${counts.skip} search index(es) skipped — Search unavailable`));
|
|
372
|
+
}
|
|
373
|
+
let building = 0;
|
|
374
|
+
let stale = 0;
|
|
375
|
+
let failed = 0;
|
|
376
|
+
for (const index of result.search?.notReady ?? []) {
|
|
377
|
+
const state = searchBuildState(index);
|
|
378
|
+
if (state === 'failed') failed += 1;
|
|
379
|
+
else if (state === 'stale') stale += 1;
|
|
380
|
+
else building += 1;
|
|
381
|
+
}
|
|
382
|
+
if (building > 0) parts.push(`${building} search index(es) building`);
|
|
383
|
+
if (stale > 0) parts.push(colors.yellow(`${stale} search index(es) stale`));
|
|
384
|
+
if (failed > 0) parts.push(colors.red(`${failed} search index(es) failed`));
|
|
326
385
|
if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
|
|
327
386
|
const line = parts.join(' · ');
|
|
328
387
|
if (cells.length === 0) return line;
|
package/src/core/audit.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
const { mapLimit } = require('../utils/concurrency.js');
|
|
1
2
|
const { errorText } = require('../utils/error.js');
|
|
3
|
+
const { SEARCH_UNAVAILABLE_HINT, listSearchIndexes, probeSearch } = require('./converge-search.js');
|
|
2
4
|
const { toLockInfo } = require('./lock.js');
|
|
5
|
+
const { normalizeLiveSearchIndex, searchBuildState } = require('./search-index-spec.js');
|
|
6
|
+
const { READ_OPTIONS, readServer } = require('./server-info.js');
|
|
3
7
|
|
|
4
8
|
/** Changelog indexes ensureIndexes() creates — audit warns when any is absent */
|
|
5
9
|
const EXPECTED_INDEXES = [
|
|
@@ -12,14 +16,16 @@ const EXPECTED_INDEXES = [
|
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Read-only health check of the setup: configuration, connectivity,
|
|
15
|
-
* transaction support, indexes, lock state and
|
|
19
|
+
* transaction support, indexes, lock state, checksum drift — and Atlas Search,
|
|
20
|
+
* when declared collections hold search indexes.
|
|
16
21
|
*
|
|
17
22
|
* Fixes nothing — it reports, so an operator can see in one command why a
|
|
18
23
|
* migration would fail before running one. Every check is independent: a
|
|
19
24
|
* failure in one is recorded and the rest still run.
|
|
20
25
|
*
|
|
21
26
|
* Pure orchestration over capabilities the MigratorKit injects (`deps`), so it
|
|
22
|
-
* needs none of the kit's private state.
|
|
27
|
+
* needs none of the kit's private state. `deps.definitions()` (optional) gives
|
|
28
|
+
* the declared collections, for the search check.
|
|
23
29
|
*/
|
|
24
30
|
async function runAudit(deps) {
|
|
25
31
|
const checks = [];
|
|
@@ -133,7 +139,20 @@ async function runAudit(deps) {
|
|
|
133
139
|
record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
// 7.
|
|
142
|
+
// 7. Search — only where declared collections hold search indexes.
|
|
143
|
+
await auditSearch(deps, db, config, record);
|
|
144
|
+
|
|
145
|
+
// 8. Background migrations — only where any is registered.
|
|
146
|
+
if (typeof deps.background === 'function') {
|
|
147
|
+
try {
|
|
148
|
+
const finding = await deps.background();
|
|
149
|
+
if (finding !== null) record('background', finding.status, finding.detail);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
record('background', 'warn', `Could not check background migrations: ${errorText(error)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 9. Runtime. TypeScript migrations need a runtime that can strip types.
|
|
137
156
|
// Feature detection instead of version parsing: it also catches a run
|
|
138
157
|
// under `--no-experimental-strip-types` on an otherwise capable Node.
|
|
139
158
|
const nodeVersion = process.versions.node;
|
|
@@ -152,6 +171,82 @@ async function runAudit(deps) {
|
|
|
152
171
|
return auditReport(checks);
|
|
153
172
|
}
|
|
154
173
|
|
|
174
|
+
/** Search index lists the audit reads at once */
|
|
175
|
+
const AUDIT_READ_CONCURRENCY = 8;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether the server has the Atlas Search that declared search indexes need,
|
|
179
|
+
* and whether any of them failed to build. Records nothing when none is
|
|
180
|
+
* declared — or when the definitions do not load: converge reports that
|
|
181
|
+
* itself, with every issue.
|
|
182
|
+
*/
|
|
183
|
+
async function auditSearch(deps, db, config, record) {
|
|
184
|
+
if (typeof deps.definitions !== 'function') return;
|
|
185
|
+
let definitions;
|
|
186
|
+
try {
|
|
187
|
+
definitions = await deps.definitions();
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// One pass: the collections that declare search indexes, the names each
|
|
192
|
+
// declares, and how many there are in all.
|
|
193
|
+
const declaring = [];
|
|
194
|
+
let declared = 0;
|
|
195
|
+
for (const definition of definitions) {
|
|
196
|
+
if (definition.searchIndexes === undefined) continue;
|
|
197
|
+
const names = new Set();
|
|
198
|
+
for (const index of definition.searchIndexes) names.add(index.name);
|
|
199
|
+
declaring.push({ definition, names });
|
|
200
|
+
declared += definition.searchIndexes.length;
|
|
201
|
+
}
|
|
202
|
+
if (declaring.length === 0) return;
|
|
203
|
+
const counted = `${declared} search index(es) declared in ${declaring.length} collection(s)`;
|
|
204
|
+
try {
|
|
205
|
+
const probe = await probeSearch(db, await readServer(db), {
|
|
206
|
+
collection: declaring[0].definition.name,
|
|
207
|
+
readOptions: READ_OPTIONS,
|
|
208
|
+
});
|
|
209
|
+
if (!probe.available) {
|
|
210
|
+
const skip = config.onSearchUnavailable === 'skip';
|
|
211
|
+
record(
|
|
212
|
+
'search',
|
|
213
|
+
skip ? 'warn' : 'fail',
|
|
214
|
+
`Not available on this server (${probe.reason}) — ${counted}; ` +
|
|
215
|
+
(skip
|
|
216
|
+
? "converge skips them (onSearchUnavailable: 'skip')"
|
|
217
|
+
: `converge refuses them: ${SEARCH_UNAVAILABLE_HINT}`),
|
|
218
|
+
);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const failed = [];
|
|
222
|
+
const stale = [];
|
|
223
|
+
// One list per collection, a few at a time; reported in declaration order.
|
|
224
|
+
const lists = await mapLimit(declaring, AUDIT_READ_CONCURRENCY, ({ definition }) =>
|
|
225
|
+
listSearchIndexes(db, definition.name, READ_OPTIONS),
|
|
226
|
+
);
|
|
227
|
+
for (const [position, { definition, names }] of declaring.entries()) {
|
|
228
|
+
for (const raw of lists[position]) {
|
|
229
|
+
const index = normalizeLiveSearchIndex(raw);
|
|
230
|
+
if (!names.has(index.name)) continue;
|
|
231
|
+
const state = searchBuildState(index);
|
|
232
|
+
const label = `${definition.name}.${index.name}${index.message ? ` (${index.message})` : ''}`;
|
|
233
|
+
if (state === 'failed') failed.push(label);
|
|
234
|
+
else if (state === 'stale') stale.push(label);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (failed.length > 0 || stale.length > 0) {
|
|
238
|
+
const parts = [];
|
|
239
|
+
if (failed.length > 0) parts.push(`failed to build: ${failed.join(', ')}`);
|
|
240
|
+
if (stale.length > 0) parts.push(`stale (not replicating): ${stale.join(', ')}`);
|
|
241
|
+
record('search', 'warn', `Available — ${parts.join('; ')}`);
|
|
242
|
+
} else {
|
|
243
|
+
record('search', 'pass', `Available — ${counted}`);
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
record('search', 'warn', `Could not check Atlas Search: ${errorText(error)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
155
250
|
/** Roll individual checks up into the report shape (one pass, both counters) */
|
|
156
251
|
function auditReport(checks) {
|
|
157
252
|
let failed = 0;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const { STATE_SUMMARY, probeHint } = require('./background.js');
|
|
2
|
+
const { probeOldShape } = require('./background-drift.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What `audit` says about background migrations — read-only, from what the
|
|
6
|
+
* kit injects (`deps`): the states, their partitions and leases, the files
|
|
7
|
+
* on disk, the changelog's background records and the live watchers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** How long a background migration may sit in `pending`, or `running` without progress, before audit warns */
|
|
11
|
+
const STALL_MS = 15 * 60_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What `audit` says about background migrations: `{ status, detail }` with
|
|
15
|
+
* the worst finding — or `null` when none is registered. `deps.checksumOf`
|
|
16
|
+
* reads the file on disk; `deps.backgroundRecords` the changelog's
|
|
17
|
+
* background records.
|
|
18
|
+
*/
|
|
19
|
+
async function auditFindings(deps, { now = Date.now() } = {}) {
|
|
20
|
+
const states = await deps.store.list({}, { projection: STATE_SUMMARY });
|
|
21
|
+
const records = await deps.backgroundRecords();
|
|
22
|
+
if (states.length === 0 && records.length === 0) return null;
|
|
23
|
+
const failures = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const byName = new Map();
|
|
26
|
+
const running = [];
|
|
27
|
+
for (const state of states) {
|
|
28
|
+
byName.set(state._id, state);
|
|
29
|
+
if (state.status === 'running') running.push(state._id);
|
|
30
|
+
}
|
|
31
|
+
// Read once for all of them, not once per state.
|
|
32
|
+
const live = await deps.store.liveLeasesOf(running);
|
|
33
|
+
const hints = new Map();
|
|
34
|
+
const hintOf = async (spec) => {
|
|
35
|
+
const key = `${spec.collection}\u0000${spec.field}`;
|
|
36
|
+
if (!hints.has(key)) hints.set(key, await probeHint(deps, spec));
|
|
37
|
+
return hints.get(key);
|
|
38
|
+
};
|
|
39
|
+
const counts = {};
|
|
40
|
+
for (const state of states) {
|
|
41
|
+
const name = state._id;
|
|
42
|
+
counts[state.status] = (counts[state.status] ?? 0) + 1;
|
|
43
|
+
if (state.status === 'failed') {
|
|
44
|
+
failures.push(`${name} failed${state.lastError ? ` (${state.lastError})` : ''}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const registeredAt = new Date(state.registeredAt).getTime();
|
|
48
|
+
const progressAt = new Date(
|
|
49
|
+
state.lastProgressAt ?? state.startedAt ?? state.registeredAt,
|
|
50
|
+
).getTime();
|
|
51
|
+
if (state.status === 'pending' && now - registeredAt > STALL_MS) {
|
|
52
|
+
warnings.push(
|
|
53
|
+
`${name} has been pending since ${new Date(registeredAt).toISOString()} — is a runner up?`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (state.status === 'paused') warnings.push(`${name} is paused`);
|
|
57
|
+
if (state.status === 'running') {
|
|
58
|
+
if ((live.get(name) ?? 0) === 0 && now - progressAt > STALL_MS) {
|
|
59
|
+
warnings.push(
|
|
60
|
+
`${name} is running but stalled — no lane for ${Math.round((now - progressAt) / 60_000)} min`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (state.status === 'blocked') {
|
|
65
|
+
for (const required of state.waitsFor ?? []) {
|
|
66
|
+
if (byName.get(required)?.status === 'failed') {
|
|
67
|
+
warnings.push(`${name} is blocked by ${required}, which failed`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (state.plan !== undefined) {
|
|
72
|
+
const current = await deps.store.partitionCounts(name, {
|
|
73
|
+
generation: state.generation,
|
|
74
|
+
plan: state.plan.token,
|
|
75
|
+
});
|
|
76
|
+
if (current.failed > 0 && state.status !== 'failed') {
|
|
77
|
+
warnings.push(`${name} has ${current.failed} failed partition(s)`);
|
|
78
|
+
}
|
|
79
|
+
// Of the current generation only: the done partitions of the pass before
|
|
80
|
+
// are kept on purpose (finalize drops the generation before last).
|
|
81
|
+
const orphaned = await deps.store.countForeignPlans(name, {
|
|
82
|
+
generation: state.generation,
|
|
83
|
+
plan: state.plan.token,
|
|
84
|
+
});
|
|
85
|
+
if (orphaned > 0) warnings.push(`${name} keeps ${orphaned} partition(s) of an old plan`);
|
|
86
|
+
}
|
|
87
|
+
if (state.status === 'completed') {
|
|
88
|
+
if ((state.badIds ?? []).length > 0) {
|
|
89
|
+
warnings.push(
|
|
90
|
+
`${name} completed with ${state.badIds.length} document(s) it could not migrate`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (state.spec?.mode === 'declarative' && state.direction !== 'revert') {
|
|
94
|
+
const hint = await hintOf(state.spec);
|
|
95
|
+
if (
|
|
96
|
+
hint !== undefined &&
|
|
97
|
+
(await probeOldShape(deps, state, hint).catch(() => null)) !== null
|
|
98
|
+
) {
|
|
99
|
+
warnings.push(
|
|
100
|
+
`${state.spec.collection} holds old-shape documents again (${name} completed)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const checksum = await deps.checksumOf(name);
|
|
107
|
+
if (state.checksum !== undefined && checksum !== state.checksum) {
|
|
108
|
+
warnings.push(`${name} changed on disk since it was registered (repin it)`);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
warnings.push(`${name} is registered but its file is missing`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const record of records) {
|
|
115
|
+
if (!byName.has(record.name)) {
|
|
116
|
+
warnings.push(`${record.name} is applied but its background migration is not registered`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Live drift watchers, when drift is streamed: one left to the poll for
|
|
120
|
+
// long, or one nobody has led for long, is worth a look.
|
|
121
|
+
for (const row of (await deps.watchRows?.()) ?? []) {
|
|
122
|
+
const quietMs = now - new Date(row.updatedAt).getTime();
|
|
123
|
+
if (!(quietMs > STALL_MS)) continue;
|
|
124
|
+
const minutes = Math.round(quietMs / 60_000);
|
|
125
|
+
warnings.push(
|
|
126
|
+
row.state === 'fallback'
|
|
127
|
+
? `the drift watcher of ${row._id} has fallen back to polling for ${minutes} min`
|
|
128
|
+
: `the drift watcher of ${row._id} has had no live leader for ${minutes} min`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const summary = Object.entries(counts)
|
|
132
|
+
.map(([status, n]) => `${n} ${status}`)
|
|
133
|
+
.join(', ');
|
|
134
|
+
if (failures.length > 0) return { status: 'fail', detail: [...failures, ...warnings].join('; ') };
|
|
135
|
+
if (warnings.length > 0) return { status: 'warn', detail: warnings.join('; ') };
|
|
136
|
+
return { status: 'pass', detail: `${states.length} background migration(s): ${summary}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { auditFindings };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const { errorText } = require('../utils/error.js');
|
|
2
|
+
const { STATE_SUMMARY, control, probeHint } = require('./background.js');
|
|
3
|
+
const { excludeBadIds, matchOf } = require('./background-engine.js');
|
|
4
|
+
const { READ_OPTIONS } = require('./server-info.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drift: old-shape documents that appear after a background migration
|
|
8
|
+
* completed — an old pod, a forgotten worker, another service. The probes
|
|
9
|
+
* the requires guard, the poll (`verifyBackground`) and audit share: one
|
|
10
|
+
* hinted, time-boxed look per completed forward migration. Orchestration
|
|
11
|
+
* over what the kit injects (`deps`), as in background.js.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How long one drift probe may run */
|
|
15
|
+
const DRIFT_PROBE_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
/** Statuses still at work on a collection — its drift is not drift yet */
|
|
18
|
+
const ACTIVE = new Set(['blocked', 'pending', 'running', 'paused']);
|
|
19
|
+
|
|
20
|
+
/** One indexed look for a document of a completed forward migration's old shape */
|
|
21
|
+
async function probeOldShape(deps, state, hint) {
|
|
22
|
+
const spec = state.spec;
|
|
23
|
+
return deps.db
|
|
24
|
+
.collection(spec.collection)
|
|
25
|
+
.findOne(excludeBadIds(matchOf(spec, 'forward'), state.badIds), {
|
|
26
|
+
projection: { _id: 1 },
|
|
27
|
+
hint,
|
|
28
|
+
maxTimeMS: DRIFT_PROBE_MS,
|
|
29
|
+
...READ_OPTIONS,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a completed forward migration's collection holds an old-shape
|
|
35
|
+
* document again — for the `requires` guard, which runs under the migration
|
|
36
|
+
* lock: one hinted probe, time-boxed. Where that is not possible (a step
|
|
37
|
+
* migration, no version index, a probe that ran out of time) the status is
|
|
38
|
+
* trusted — a scan of the whole collection on every `up` is not an option.
|
|
39
|
+
*/
|
|
40
|
+
async function stillDirty(deps, state) {
|
|
41
|
+
const spec = state.spec;
|
|
42
|
+
if (!spec || spec.mode !== 'declarative' || state.direction === 'revert') return false;
|
|
43
|
+
const hint = await probeHint(deps, spec);
|
|
44
|
+
if (hint === undefined) return false;
|
|
45
|
+
try {
|
|
46
|
+
return (await probeOldShape(deps, state, hint)) !== null;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
deps.logger.warn(
|
|
49
|
+
`⚠ Could not check ${state._id} for old-shape documents: ${errorText(error)} — trusting its status`,
|
|
50
|
+
deps.fields({ background: state._id }),
|
|
51
|
+
);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The drift watch: old-shape documents that appeared after a background
|
|
58
|
+
* migration completed — an old pod, a forgotten worker, another service.
|
|
59
|
+
* One indexed probe per completed forward (declarative) migration; skipped
|
|
60
|
+
* where another one is still at work on the collection, where the validator
|
|
61
|
+
* already refuses the old shape (`to ≤ versioning.min`), and where the
|
|
62
|
+
* version index is missing. With `onDrift: 'reopen'` a finding reopens it —
|
|
63
|
+
* a new pass over what is left, not a reset — and with `'report'` it is only
|
|
64
|
+
* said. Chains (v1→v2→v3) converge on their own. No document id is reported.
|
|
65
|
+
* `streaming`: collections a live watcher leads right now — skipped too.
|
|
66
|
+
*/
|
|
67
|
+
async function verify(deps, { onDrift = 'reopen', collections, streaming } = {}) {
|
|
68
|
+
const states = await deps.store.list({}, { projection: STATE_SUMMARY });
|
|
69
|
+
const active = new Set();
|
|
70
|
+
for (const state of states) {
|
|
71
|
+
if (ACTIVE.has(state.status) && state.spec?.collection) active.add(state.spec.collection);
|
|
72
|
+
}
|
|
73
|
+
// In `backgroundDrift: 'stream'` mode a live watcher's collection is its, not the poll's.
|
|
74
|
+
for (const collection of streaming ?? []) active.add(collection);
|
|
75
|
+
const wanted = collections === undefined ? undefined : new Set(collections);
|
|
76
|
+
const result = { checked: 0, skipped: 0, drift: [] };
|
|
77
|
+
for (const state of states) {
|
|
78
|
+
const spec = state.spec;
|
|
79
|
+
if (state.status !== 'completed' || state.direction === 'revert') continue;
|
|
80
|
+
if (spec?.mode !== 'declarative') continue;
|
|
81
|
+
if (wanted !== undefined && !wanted.has(spec.collection)) continue;
|
|
82
|
+
const versioning = await deps.versioningOf?.(spec.collection);
|
|
83
|
+
if (active.has(spec.collection) || (versioning && spec.to <= versioning.min)) {
|
|
84
|
+
result.skipped += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const hint = await probeHint(deps, spec);
|
|
88
|
+
if (hint === undefined) {
|
|
89
|
+
result.skipped += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let found;
|
|
93
|
+
try {
|
|
94
|
+
found = await probeOldShape(deps, state, hint);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
deps.logger.warn(
|
|
97
|
+
`⚠ Drift check of ${state._id} failed: ${errorText(error)}`,
|
|
98
|
+
deps.fields({ background: state._id }),
|
|
99
|
+
);
|
|
100
|
+
result.skipped += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
result.checked += 1;
|
|
104
|
+
if (found === null) continue;
|
|
105
|
+
const action = onDrift === 'reopen' ? 'reopened' : 'reported';
|
|
106
|
+
if (action === 'reopened') {
|
|
107
|
+
await control(deps, state._id, 'retry', { reason: 'old-shape documents reappeared' });
|
|
108
|
+
}
|
|
109
|
+
deps.telemetry?.backgroundDrift({ name: state._id });
|
|
110
|
+
deps.emit('background:drift', {
|
|
111
|
+
migration: state._id,
|
|
112
|
+
collection: spec.collection,
|
|
113
|
+
source: 'poll',
|
|
114
|
+
action,
|
|
115
|
+
});
|
|
116
|
+
deps.logger.warn(
|
|
117
|
+
`⚠ ${spec.collection}: old-shape documents appeared after ${state._id} completed — ` +
|
|
118
|
+
(action === 'reopened' ? 'reopened it' : 'an old release may still be writing'),
|
|
119
|
+
deps.fields({ background: state._id, collection: spec.collection, action }),
|
|
120
|
+
);
|
|
121
|
+
result.drift.push({ migration: state._id, collection: spec.collection, action });
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { probeOldShape, stillDirty, verify };
|