@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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
const { ConfigInvalidError, LockAlreadyHeldError } = require('../../errors/index.js');
|
|
2
|
+
const { createColors } = require('../../utils/colors.js');
|
|
3
|
+
const { errorText } = require('../../utils/error.js');
|
|
4
|
+
const { confirm, defineCommand, EXIT_CODES } = require('../shared.js');
|
|
5
|
+
const { renderTable } = require('../table.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `migronaut background <action> [name]` — background migrations from the
|
|
9
|
+
* command line: see them, run them, control them, dry-run them, watch for
|
|
10
|
+
* drift. One command with an action (the argument parser has one level of
|
|
11
|
+
* subcommands); each action takes the flags it needs and refuses the others.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ACTIONS = new Set([
|
|
15
|
+
'status',
|
|
16
|
+
'run',
|
|
17
|
+
'pause',
|
|
18
|
+
'resume',
|
|
19
|
+
'cancel',
|
|
20
|
+
'retry',
|
|
21
|
+
'repin',
|
|
22
|
+
'dry-run',
|
|
23
|
+
'unlock',
|
|
24
|
+
'verify',
|
|
25
|
+
'watch',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Which flags each action takes (besides the global ones) */
|
|
29
|
+
const FLAGS = {
|
|
30
|
+
status: ['partitions', 'check'],
|
|
31
|
+
run: ['all', 'concurrency', 'once'],
|
|
32
|
+
pause: ['wait', 'reason'],
|
|
33
|
+
resume: ['reason'],
|
|
34
|
+
cancel: ['wait', 'reason', 'yes'],
|
|
35
|
+
retry: ['fromStart', 'repin', 'reason', 'yes'],
|
|
36
|
+
repin: ['reason', 'yes'],
|
|
37
|
+
'dry-run': [
|
|
38
|
+
'sample',
|
|
39
|
+
'first',
|
|
40
|
+
'validate',
|
|
41
|
+
'steps',
|
|
42
|
+
'revert',
|
|
43
|
+
'maxDocs',
|
|
44
|
+
'fromStart',
|
|
45
|
+
'deadlineMs',
|
|
46
|
+
],
|
|
47
|
+
unlock: ['yes'],
|
|
48
|
+
verify: ['report'],
|
|
49
|
+
watch: ['report'],
|
|
50
|
+
};
|
|
51
|
+
const ALL_FLAGS = new Set(Object.values(FLAGS).flat());
|
|
52
|
+
|
|
53
|
+
/** What `run --all` drives */
|
|
54
|
+
const RUNNABLE = new Set(['blocked', 'pending', 'running']);
|
|
55
|
+
|
|
56
|
+
/** Actions a name is required for */
|
|
57
|
+
const NEEDS_NAME = new Set(['pause', 'resume', 'cancel', 'retry', 'repin', 'dry-run', 'unlock']);
|
|
58
|
+
|
|
59
|
+
const flagName = (key) => `--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
60
|
+
|
|
61
|
+
/** A positive integer flag, or undefined when not given */
|
|
62
|
+
function integerFlag(opts, key, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
63
|
+
if (opts[key] === undefined) return undefined;
|
|
64
|
+
const value = Number(opts[key]);
|
|
65
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
66
|
+
throw new ConfigInvalidError(`${flagName(key)} must be an integer from ${min} to ${max}`, {
|
|
67
|
+
[key]: opts[key],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Check the action, its name and its flags — before anything connects */
|
|
74
|
+
function preflight(opts, [action, name]) {
|
|
75
|
+
if (!ACTIONS.has(action)) {
|
|
76
|
+
throw new ConfigInvalidError(
|
|
77
|
+
`Unknown background action "${action}" (one of: ${[...ACTIONS].join(', ')})`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (NEEDS_NAME.has(action) && name === undefined) {
|
|
81
|
+
throw new ConfigInvalidError(`background ${action} needs a migration name`);
|
|
82
|
+
}
|
|
83
|
+
if (action === 'run' && (name === undefined) === !opts.all) {
|
|
84
|
+
throw new ConfigInvalidError('background run takes a migration name, or --all');
|
|
85
|
+
}
|
|
86
|
+
if (action === 'verify' && name !== undefined) {
|
|
87
|
+
throw new ConfigInvalidError('background verify checks every background migration — no name');
|
|
88
|
+
}
|
|
89
|
+
const allowed = new Set(FLAGS[action]);
|
|
90
|
+
for (const key of ALL_FLAGS) {
|
|
91
|
+
if (opts[key] !== undefined && opts[key] !== false && !allowed.has(key)) {
|
|
92
|
+
throw new ConfigInvalidError(`${flagName(key)} does not apply to background ${action}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
integerFlag(opts, 'concurrency', { max: 64 });
|
|
96
|
+
integerFlag(opts, 'sample', { max: 1000 });
|
|
97
|
+
integerFlag(opts, 'first', { max: 1000 });
|
|
98
|
+
integerFlag(opts, 'steps', { max: 50 });
|
|
99
|
+
integerFlag(opts, 'maxDocs', { max: 1000 });
|
|
100
|
+
integerFlag(opts, 'deadlineMs', { max: 50_000 });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The controls that cannot be taken back ask first; `--json` needs `--yes` */
|
|
105
|
+
async function confirmed(action, state, { opts, json, logger }) {
|
|
106
|
+
const asks =
|
|
107
|
+
action === 'cancel' ||
|
|
108
|
+
action === 'repin' ||
|
|
109
|
+
action === 'unlock' ||
|
|
110
|
+
(action === 'retry' && opts.fromStart);
|
|
111
|
+
if (!asks || opts.yes) return true;
|
|
112
|
+
if (json) {
|
|
113
|
+
throw new ConfigInvalidError(
|
|
114
|
+
`background ${action} needs confirmation — pass --yes in --json mode`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (state) {
|
|
118
|
+
logger.warn(
|
|
119
|
+
`⚠ ${state.migration} is ${state.status} (pass ${state.pass}, ` +
|
|
120
|
+
`${state.totals.migrated ?? 0} migrated, ${state.liveLeases} lane(s) working)`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const what = action === 'retry' ? 'retry from the start' : action;
|
|
124
|
+
return confirm(`${what[0].toUpperCase()}${what.slice(1)} ${state?.migration ?? 'it'}? [y/N] `);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `watch`: the live drift watcher in the foreground — every collection with a
|
|
129
|
+
* completed background migration, or the one named — until SIGINT or
|
|
130
|
+
* SIGTERM, which end it cleanly (exit 0): streams closed, positions saved,
|
|
131
|
+
* locks released. What it does is printed as it happens.
|
|
132
|
+
*/
|
|
133
|
+
async function watchAction(migrator, opts, collection, { logger, json }) {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const onWatch = (event) => logger.info(`… ${event.collection}: ${event.state}`);
|
|
136
|
+
const onDrift = (event) => {
|
|
137
|
+
if (event.source !== 'stream') return;
|
|
138
|
+
const line = `${event.collection}: ${event.action} (${event.migration})`;
|
|
139
|
+
if (event.action === 'upgraded') logger.info(`✔ ${line}`);
|
|
140
|
+
else logger.warn(`⚠ ${line}`);
|
|
141
|
+
};
|
|
142
|
+
if (!json) {
|
|
143
|
+
migrator.on('background:watch', onWatch);
|
|
144
|
+
migrator.on('background:drift', onDrift);
|
|
145
|
+
}
|
|
146
|
+
let stop;
|
|
147
|
+
const stopped = new Promise((resolve) => {
|
|
148
|
+
stop = resolve;
|
|
149
|
+
});
|
|
150
|
+
const handlers = ['SIGINT', 'SIGTERM'].map((signal) => [
|
|
151
|
+
signal,
|
|
152
|
+
() => {
|
|
153
|
+
controller.abort();
|
|
154
|
+
stop();
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
for (const [signal, handler] of handlers) process.on(signal, handler);
|
|
158
|
+
try {
|
|
159
|
+
const watcher = await migrator.watchBackground({
|
|
160
|
+
...(collection !== undefined ? { collections: [collection] } : {}),
|
|
161
|
+
...(opts.report ? { upgrade: false } : {}),
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
onError: (error, where) =>
|
|
164
|
+
logger.warn(`⚠ Drift watcher${where ? ` (${where})` : ''}: ${errorText(error)}`),
|
|
165
|
+
});
|
|
166
|
+
if (!json) logger.info('Watching for old-shape writes — Ctrl-C to stop');
|
|
167
|
+
await stopped;
|
|
168
|
+
const rows = watcher.status();
|
|
169
|
+
await watcher.stop();
|
|
170
|
+
return { watch: rows };
|
|
171
|
+
} finally {
|
|
172
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
173
|
+
migrator.off('background:watch', onWatch);
|
|
174
|
+
migrator.off('background:drift', onDrift);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `run`: drive one background migration (or every runnable one, in order) from here */
|
|
179
|
+
async function runAction(migrator, opts, name, { logger }) {
|
|
180
|
+
const concurrency = integerFlag(opts, 'concurrency', { max: 64 }) ?? 1;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
let stopping = false;
|
|
183
|
+
const onSignal = (signal) => {
|
|
184
|
+
if (stopping) process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
185
|
+
stopping = true;
|
|
186
|
+
logger.warn(
|
|
187
|
+
`⚠ ${signal} received — the lanes stop at their next batch; the background migration ` +
|
|
188
|
+
'goes on from there next time. Press again to exit immediately.',
|
|
189
|
+
);
|
|
190
|
+
controller.abort();
|
|
191
|
+
};
|
|
192
|
+
const handlers = ['SIGINT', 'SIGTERM'].map((signal) => [signal, () => onSignal(signal)]);
|
|
193
|
+
for (const [signal, handler] of handlers) process.on(signal, handler);
|
|
194
|
+
try {
|
|
195
|
+
const runOne = async (target) => {
|
|
196
|
+
const status = await migrator.backgroundStatus(target);
|
|
197
|
+
if (status === null) {
|
|
198
|
+
throw new ConfigInvalidError(
|
|
199
|
+
`Background migration ${target} is not registered — run up first`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (concurrency > status.maxParallel) {
|
|
203
|
+
logger.warn(
|
|
204
|
+
`⚠ --concurrency ${concurrency} is more than ${target}'s maxParallel ` +
|
|
205
|
+
`(${status.maxParallel}) — using ${status.maxParallel}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (opts.once) {
|
|
209
|
+
const answer = await migrator.coordinateBackground(target, {
|
|
210
|
+
signal: controller.signal,
|
|
211
|
+
driver: { kind: 'cli' },
|
|
212
|
+
});
|
|
213
|
+
if (answer.next === 'busy') {
|
|
214
|
+
throw new LockAlreadyHeldError(`Another process is coordinating ${target} right now`, {
|
|
215
|
+
migration: target,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return migrator.runBackground(target, {
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
concurrency,
|
|
222
|
+
untilDone: !opts.once,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
if (!opts.all) return [await runOne(name)];
|
|
226
|
+
// Round after round: one that waited for another (run later in the same
|
|
227
|
+
// round) is unblocked when that one completes, and runs in the next.
|
|
228
|
+
// Done when a round changes nothing, or after one with --once.
|
|
229
|
+
const results = new Map();
|
|
230
|
+
let previous;
|
|
231
|
+
for (;;) {
|
|
232
|
+
const runnable = [];
|
|
233
|
+
for (const state of await migrator.backgroundStatus()) {
|
|
234
|
+
if (RUNNABLE.has(state.status)) runnable.push(state.migration);
|
|
235
|
+
}
|
|
236
|
+
const key = runnable.join('\n');
|
|
237
|
+
if (runnable.length === 0 || key === previous) break;
|
|
238
|
+
previous = key;
|
|
239
|
+
for (const target of runnable) results.set(target, await runOne(target));
|
|
240
|
+
if (opts.once) break;
|
|
241
|
+
}
|
|
242
|
+
return [...results.values()];
|
|
243
|
+
} finally {
|
|
244
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const STATUS_COLORS = {
|
|
249
|
+
completed: 'green',
|
|
250
|
+
running: 'cyan',
|
|
251
|
+
pending: 'yellow',
|
|
252
|
+
blocked: 'yellow',
|
|
253
|
+
paused: 'yellow',
|
|
254
|
+
failed: 'red',
|
|
255
|
+
cancelled: 'dim',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function renderStatuses(states) {
|
|
259
|
+
const colors = createColors(process.stdout);
|
|
260
|
+
const rows = states.map((state) => {
|
|
261
|
+
const color = colors[STATUS_COLORS[state.status]] ?? ((text) => text);
|
|
262
|
+
const parts = state.partitions;
|
|
263
|
+
return [
|
|
264
|
+
state.migration,
|
|
265
|
+
color(state.status),
|
|
266
|
+
state.collection ?? '—',
|
|
267
|
+
state.from !== undefined ? `${state.from} → ${state.to}` : 'step',
|
|
268
|
+
String(state.pass),
|
|
269
|
+
parts ? `${parts.done}/${parts.total}` : '—',
|
|
270
|
+
`${state.liveLeases}/${state.maxParallel}`,
|
|
271
|
+
String(state.totals.migrated ?? 0),
|
|
272
|
+
state.waitsFor.length > 0
|
|
273
|
+
? `waits for ${state.waitsFor.join(', ')}`
|
|
274
|
+
: (state.lastError ?? ''),
|
|
275
|
+
];
|
|
276
|
+
});
|
|
277
|
+
return renderTable(
|
|
278
|
+
[
|
|
279
|
+
'Background migration',
|
|
280
|
+
'Status',
|
|
281
|
+
'Collection',
|
|
282
|
+
'Versions',
|
|
283
|
+
'Pass',
|
|
284
|
+
'Partitions',
|
|
285
|
+
'Lanes',
|
|
286
|
+
'Migrated',
|
|
287
|
+
'Note',
|
|
288
|
+
],
|
|
289
|
+
rows,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderPartitions(partitions) {
|
|
294
|
+
return renderTable(
|
|
295
|
+
['#', 'Status', 'Scope', 'Migrated', 'Lease', 'Claims', 'Error'],
|
|
296
|
+
partitions.map((partition) => [
|
|
297
|
+
String(partition.seq),
|
|
298
|
+
partition.status,
|
|
299
|
+
partition.scope.kind === 'step'
|
|
300
|
+
? 'step'
|
|
301
|
+
: `${partition.scope.bracket ?? ''}${partition.group ? ` @${partition.group}` : ''}`,
|
|
302
|
+
String(partition.counters.migrated ?? 0),
|
|
303
|
+
partition.lease
|
|
304
|
+
? `slot ${partition.lease.slot} · ${partition.lease.host}:${partition.lease.pid}`
|
|
305
|
+
: '',
|
|
306
|
+
String(partition.claims),
|
|
307
|
+
partition.lastError ?? '',
|
|
308
|
+
]),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Register the `background` command */
|
|
313
|
+
function registerBackground(program) {
|
|
314
|
+
defineCommand(program, {
|
|
315
|
+
name: 'background',
|
|
316
|
+
description:
|
|
317
|
+
'Background migrations: status, run, pause, resume, cancel, retry, repin, dry-run, unlock, ' +
|
|
318
|
+
'verify, watch',
|
|
319
|
+
args: [
|
|
320
|
+
[
|
|
321
|
+
'<action>',
|
|
322
|
+
'status | run | pause | resume | cancel | retry | repin | dry-run | unlock | verify | watch',
|
|
323
|
+
],
|
|
324
|
+
['[name]', 'The background migration (its file name) — watch: a collection'],
|
|
325
|
+
],
|
|
326
|
+
options: [
|
|
327
|
+
['--partitions', 'status: list the partitions of the latest generation'],
|
|
328
|
+
[
|
|
329
|
+
'--check',
|
|
330
|
+
`status: exit ${EXIT_CODES.BACKGROUND_FAILED} if one failed, ${EXIT_CODES.BACKGROUND_PENDING} if one is not completed`,
|
|
331
|
+
],
|
|
332
|
+
['--all', 'run: every runnable background migration, in order'],
|
|
333
|
+
['--concurrency <n>', 'run: lanes in this process (at most its maxParallel; default 1)'],
|
|
334
|
+
['--once', 'run: one round, not to the end'],
|
|
335
|
+
['--wait', 'pause, cancel: wait until no lane works any more'],
|
|
336
|
+
['--from-start', 'retry: plan everything again; dry-run: from no checkpoint'],
|
|
337
|
+
['--repin', 'retry: pin the file on disk first'],
|
|
338
|
+
['--sample <n>', 'dry-run: a random sample of n documents (default 5)'],
|
|
339
|
+
['--first <n>', 'dry-run: the first n documents by _id'],
|
|
340
|
+
['--validate', 'dry-run: through the real write path, in an always-aborted transaction'],
|
|
341
|
+
['--steps <k>', 'dry-run: run k steps of a step migration (default 1)'],
|
|
342
|
+
['--revert', 'dry-run: the way back'],
|
|
343
|
+
['--max-docs <n>', 'dry-run: document images to keep (default 20)'],
|
|
344
|
+
['--deadline-ms <ms>', 'dry-run: stop the sandbox after this long (default 50000)'],
|
|
345
|
+
['--report', 'verify: only report drift, never reopen; watch: never upgrade'],
|
|
346
|
+
['--reason <text>', 'controls: why — recorded in its history'],
|
|
347
|
+
['-y, --yes', 'cancel, retry --from-start, repin, unlock: do not ask (required with --json)'],
|
|
348
|
+
],
|
|
349
|
+
spinner: false,
|
|
350
|
+
preflight: (opts, positionals) => preflight(opts, positionals),
|
|
351
|
+
run: async (migrator, opts, [action, name], cli) => {
|
|
352
|
+
const control = {
|
|
353
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
354
|
+
...(opts.wait ? { wait: true } : {}),
|
|
355
|
+
};
|
|
356
|
+
switch (action) {
|
|
357
|
+
case 'status': {
|
|
358
|
+
if (opts.partitions) {
|
|
359
|
+
if (name === undefined) {
|
|
360
|
+
throw new ConfigInvalidError('background status --partitions needs a name');
|
|
361
|
+
}
|
|
362
|
+
return { partitions: await migrator.backgroundPartitions(name) };
|
|
363
|
+
}
|
|
364
|
+
if (name !== undefined) {
|
|
365
|
+
const one = await migrator.backgroundStatus(name);
|
|
366
|
+
if (one === null) {
|
|
367
|
+
throw new ConfigInvalidError(`Background migration ${name} is not registered`);
|
|
368
|
+
}
|
|
369
|
+
return { background: [one] };
|
|
370
|
+
}
|
|
371
|
+
return { background: await migrator.backgroundStatus() };
|
|
372
|
+
}
|
|
373
|
+
case 'run':
|
|
374
|
+
return { background: await runAction(migrator, opts, name, cli) };
|
|
375
|
+
case 'pause':
|
|
376
|
+
return migrator.pauseBackground(name, control);
|
|
377
|
+
case 'resume':
|
|
378
|
+
return migrator.resumeBackground(name, control);
|
|
379
|
+
case 'cancel':
|
|
380
|
+
case 'repin':
|
|
381
|
+
case 'retry':
|
|
382
|
+
case 'unlock': {
|
|
383
|
+
const state = await migrator.backgroundStatus(name);
|
|
384
|
+
if (!(await confirmed(action, state, { ...cli, opts }))) {
|
|
385
|
+
cli.logger.info('Aborted');
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
if (action === 'cancel') return migrator.cancelBackground(name, control);
|
|
389
|
+
if (action === 'repin') return migrator.repinBackground(name, control);
|
|
390
|
+
if (action === 'unlock') return migrator.unlockBackground(name);
|
|
391
|
+
return migrator.retryBackground(name, {
|
|
392
|
+
...control,
|
|
393
|
+
...(opts.fromStart ? { fromStart: true } : {}),
|
|
394
|
+
...(opts.repin ? { repin: true } : {}),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
case 'dry-run':
|
|
398
|
+
return {
|
|
399
|
+
dryRun: await migrator.dryRunBackground(name, {
|
|
400
|
+
...(opts.sample !== undefined ? { sample: Number(opts.sample) } : {}),
|
|
401
|
+
...(opts.first !== undefined ? { first: Number(opts.first) } : {}),
|
|
402
|
+
...(opts.validate ? { validate: true } : {}),
|
|
403
|
+
...(opts.steps !== undefined ? { steps: Number(opts.steps) } : {}),
|
|
404
|
+
...(opts.revert ? { direction: 'revert' } : {}),
|
|
405
|
+
...(opts.maxDocs !== undefined ? { maxDocuments: Number(opts.maxDocs) } : {}),
|
|
406
|
+
...(opts.fromStart ? { fromStart: true } : {}),
|
|
407
|
+
...(opts.deadlineMs !== undefined ? { deadlineMs: Number(opts.deadlineMs) } : {}),
|
|
408
|
+
}),
|
|
409
|
+
};
|
|
410
|
+
case 'watch':
|
|
411
|
+
return watchAction(migrator, opts, name, cli);
|
|
412
|
+
default:
|
|
413
|
+
return {
|
|
414
|
+
verify: await migrator.verifyBackground(opts.report ? { onDrift: 'report' } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
render: (data, { logger }) => {
|
|
419
|
+
if (data.partitions) {
|
|
420
|
+
logger.info(renderPartitions(data.partitions));
|
|
421
|
+
} else if (data.background) {
|
|
422
|
+
if (data.background.length === 0) logger.info('No background migrations registered');
|
|
423
|
+
else logger.info(renderStatuses(data.background));
|
|
424
|
+
} else if (data.dryRun) {
|
|
425
|
+
renderDryRun(data.dryRun, logger);
|
|
426
|
+
} else if (data.verify) {
|
|
427
|
+
const { checked, skipped, drift } = data.verify;
|
|
428
|
+
if (drift.length === 0) {
|
|
429
|
+
logger.info(`✔ No drift (${checked} checked, ${skipped} skipped)`);
|
|
430
|
+
}
|
|
431
|
+
for (const entry of drift) {
|
|
432
|
+
logger.warn(
|
|
433
|
+
`⚠ ${entry.collection}: old-shape documents after ${entry.migration} — ${entry.action}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
} else if (data.watch) {
|
|
437
|
+
let upgraded = 0;
|
|
438
|
+
for (const row of data.watch) upgraded += row.counters.upgraded;
|
|
439
|
+
logger.info(`✔ Stopped watching — ${upgraded} document(s) upgraded`);
|
|
440
|
+
} else if (data.applied !== undefined) {
|
|
441
|
+
logger.info(`✔ ${data.applied === 'changed' ? 'Done' : 'Nothing to do'} — ${data.status}`);
|
|
442
|
+
} else if (data.leases !== undefined) {
|
|
443
|
+
logger.info(
|
|
444
|
+
`✔ Released the coordinator lock${data.lock ? '' : ' (none held)'} and ${data.leases} lease(s)`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
after: (data, { opts, logger }) => {
|
|
449
|
+
if (data === undefined) return;
|
|
450
|
+
const states = data.background;
|
|
451
|
+
if (opts.check && Array.isArray(states)) {
|
|
452
|
+
if (states.some((state) => state.status === 'failed')) {
|
|
453
|
+
logger.error('✖ A background migration failed');
|
|
454
|
+
process.exitCode = EXIT_CODES.BACKGROUND_FAILED;
|
|
455
|
+
} else if (states.some((state) => state.status !== 'completed')) {
|
|
456
|
+
logger.error('✖ Background migrations are not all completed');
|
|
457
|
+
process.exitCode = EXIT_CODES.BACKGROUND_PENDING;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (data.verify?.drift.length > 0) process.exitCode = EXIT_CODES.BACKGROUND_PENDING;
|
|
461
|
+
const dry = data.dryRun;
|
|
462
|
+
if (dry !== undefined) {
|
|
463
|
+
if (dry.mode === 'step') {
|
|
464
|
+
if (dry.refusals.length > 0) process.exitCode = EXIT_CODES.SANDBOX_REFUSED;
|
|
465
|
+
else if (!dry.ok) process.exitCode = 1;
|
|
466
|
+
} else if (dry.refusals?.length > 0) {
|
|
467
|
+
process.exitCode = EXIT_CODES.SANDBOX_REFUSED;
|
|
468
|
+
} else if (dry.migrated === 0) {
|
|
469
|
+
process.exitCode = 1;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** A dry run, for people: one line per document or step, then what the sandbox saw */
|
|
477
|
+
function renderDryRun(dry, logger) {
|
|
478
|
+
if (dry.mode === 'step') {
|
|
479
|
+
for (const step of dry.steps) {
|
|
480
|
+
logger.info(
|
|
481
|
+
`◎ Step ${step.step}: ${JSON.stringify(step.checkpointIn)} → ` +
|
|
482
|
+
`${step.error ? `✖ ${step.error}` : JSON.stringify(step.checkpointOut)}${step.done ? ' (done)' : ''}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
for (const row of dry.documents) {
|
|
487
|
+
const id = JSON.stringify(row._id);
|
|
488
|
+
if (row.error) logger.warn(`✖ ${id}: ${row.error}`);
|
|
489
|
+
else logger.info(`✔ ${id}: ${JSON.stringify(row.change ?? row.after)}`);
|
|
490
|
+
}
|
|
491
|
+
logger.info(`◎ ${dry.migrated} of ${dry.found} would be migrated, ${dry.failed} would fail`);
|
|
492
|
+
}
|
|
493
|
+
for (const refusal of dry.refusals ?? []) {
|
|
494
|
+
logger.error(`✖ Refused ${refusal.method}: ${refusal.reason}`);
|
|
495
|
+
}
|
|
496
|
+
if (dry.ops) logger.info(`◎ ${dry.ops.length} operation(s), all rolled back`);
|
|
497
|
+
if (dry.stoppedBy === 'deadline') logger.warn('⚠ Stopped at the deadline');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
module.exports = { registerBackground };
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
const { needsConfirmation } = require('../../core/converge-plan.js');
|
|
2
|
+
const { searchBuildState } = require('../../core/search-index-spec.js');
|
|
2
3
|
const { ConfigInvalidError, RunAbortedError } = require('../../errors/index.js');
|
|
3
4
|
const { confirm, defineCommand, EXIT_CODES } = require('../shared.js');
|
|
4
5
|
const { renderConvergeHistory, renderConvergeTable } = require('../table.js');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Every row the operator must confirm, across all collections: a dropped or
|
|
8
|
-
* rebuilt index, or a validator change on a
|
|
9
|
+
* rebuilt index, a dropped search index, or a validator change on a
|
|
10
|
+
* collection that holds data.
|
|
9
11
|
*/
|
|
10
12
|
function actionsToConfirm(plan) {
|
|
11
13
|
const found = [];
|
|
@@ -36,14 +38,18 @@ function assertNotStopped(stopRequested) {
|
|
|
36
38
|
function registerConverge(program) {
|
|
37
39
|
defineCommand(program, {
|
|
38
40
|
name: 'converge',
|
|
39
|
-
description:
|
|
41
|
+
description:
|
|
42
|
+
'Bring declared collections (indexes, search indexes, validators) to their declared state',
|
|
40
43
|
options: [
|
|
41
44
|
['--dry-run', 'Show what would change without changing anything'],
|
|
42
45
|
[
|
|
43
46
|
'--check',
|
|
44
47
|
`Exit with code ${EXIT_CODES.COLLECTIONS_DRIFT} if anything would change (CI gate; implies --dry-run)`,
|
|
45
48
|
],
|
|
46
|
-
[
|
|
49
|
+
[
|
|
50
|
+
'--prune',
|
|
51
|
+
'Drop undeclared indexes and search indexes (in collections whose definition does not decide)',
|
|
52
|
+
],
|
|
47
53
|
['--ordered', 'Refuse while any migration is still pending'],
|
|
48
54
|
['--reason <text>', 'Why — recorded in the converge history (who: the OS user)'],
|
|
49
55
|
['--history', 'Show the converge history instead of converging (read-only)'],
|
|
@@ -52,6 +58,11 @@ function registerConverge(program) {
|
|
|
52
58
|
'--rebuild-unique',
|
|
53
59
|
'Allow rebuilding a unique index (drops the constraint until the new one is built)',
|
|
54
60
|
],
|
|
61
|
+
[
|
|
62
|
+
'--wait-search',
|
|
63
|
+
'Wait until every declared search index is queryable (overrides waitForSearchIndexes)',
|
|
64
|
+
],
|
|
65
|
+
['--no-wait-search', 'Do not wait for search indexes, whatever waitForSearchIndexes says'],
|
|
55
66
|
[
|
|
56
67
|
'-y, --yes',
|
|
57
68
|
'Drop and rebuild indexes, and change validators, without asking (required with --json)',
|
|
@@ -64,6 +75,13 @@ function registerConverge(program) {
|
|
|
64
75
|
// ask only when the plan drops or rebuilds an index, then apply — the way
|
|
65
76
|
// `unlock` reads the lock before asking.
|
|
66
77
|
run: async (migrator, opts, _positionals, { logger, json, spinner, stopRequested }) => {
|
|
78
|
+
const waitSearch = typeof opts.waitSearch === 'boolean' ? opts.waitSearch : undefined;
|
|
79
|
+
if (waitSearch !== undefined && (opts.history || opts.dryRun || opts.check)) {
|
|
80
|
+
throw new ConfigInvalidError(
|
|
81
|
+
`--${waitSearch ? '' : 'no-'}wait-search applies to a real converge — not to ` +
|
|
82
|
+
`${opts.history ? '--history' : opts.check ? '--check' : '--dry-run'}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
67
85
|
if (opts.history) {
|
|
68
86
|
return migrator.convergeHistory(
|
|
69
87
|
opts.limit !== undefined ? { limit: Number(opts.limit) } : {},
|
|
@@ -98,16 +116,13 @@ function registerConverge(program) {
|
|
|
98
116
|
// applies without one.
|
|
99
117
|
if (json) {
|
|
100
118
|
throw new ConfigInvalidError(
|
|
101
|
-
`converge would drop or rebuild an index, or change a validator ` +
|
|
119
|
+
`converge would drop or rebuild an index, drop a search index, or change a validator ` +
|
|
102
120
|
`(${destructive.length} change(s)) — pass --yes to confirm in --json mode`,
|
|
103
121
|
{ destructive },
|
|
104
122
|
);
|
|
105
123
|
}
|
|
106
124
|
logger.info(renderConvergeTable(plan));
|
|
107
|
-
|
|
108
|
-
(action) => action.action === 'recreate' && opts.rebuildUnique,
|
|
109
|
-
);
|
|
110
|
-
if (uniqueRebuilds.length > 0) {
|
|
125
|
+
if (opts.rebuildUnique && destructive.some((action) => action.action === 'recreate')) {
|
|
111
126
|
logger.warn(
|
|
112
127
|
'⚠ --rebuild-unique: a rebuilt unique index enforces nothing until it is built ' +
|
|
113
128
|
'again — a duplicate written in between makes it unbuildable',
|
|
@@ -127,6 +142,7 @@ function registerConverge(program) {
|
|
|
127
142
|
noLock: opts.noLock,
|
|
128
143
|
...prune,
|
|
129
144
|
...ordered,
|
|
145
|
+
...(waitSearch !== undefined ? { waitForSearchIndexes: waitSearch } : {}),
|
|
130
146
|
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
131
147
|
});
|
|
132
148
|
} finally {
|
|
@@ -147,9 +163,21 @@ function registerConverge(program) {
|
|
|
147
163
|
logger.info(renderConvergeTable(result, { all: Boolean(opts.verbose) }));
|
|
148
164
|
},
|
|
149
165
|
after: (result, { logger, opts }) => {
|
|
150
|
-
if (!opts.check || result === undefined
|
|
166
|
+
if (!opts.check || result === undefined) return;
|
|
167
|
+
// A search index that failed to build serves nothing, whatever its
|
|
168
|
+
// definition says — the gate fails on it too, though converge cannot fix it.
|
|
151
169
|
// .error writes to stderr, so JSON stdout stays a single clean document.
|
|
152
|
-
|
|
170
|
+
let failed = 0;
|
|
171
|
+
for (const index of result.search?.notReady ?? []) {
|
|
172
|
+
if (searchBuildState(index) !== 'failed') continue;
|
|
173
|
+
failed += 1;
|
|
174
|
+
logger.error(
|
|
175
|
+
`✖ Search index ${index.collection} "${index.name}" failed to build` +
|
|
176
|
+
`${index.message ? `: ${index.message}` : ''}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (result.inSync && failed === 0) return;
|
|
180
|
+
if (!result.inSync) logger.error('✖ The database differs from the declared collections');
|
|
153
181
|
// A dedicated code: a CI gate must tell "out of step" (act: converge)
|
|
154
182
|
// from "the check itself crashed" (act: page).
|
|
155
183
|
process.exitCode = EXIT_CODES.COLLECTIONS_DRIFT;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { ConfigInvalidError } = require('../../errors/index.js');
|
|
1
2
|
const { defineCommand } = require('../shared.js');
|
|
2
3
|
|
|
3
4
|
/** Register the `create` command */
|
|
@@ -10,15 +11,20 @@ function registerCreate(program) {
|
|
|
10
11
|
['--js', 'Force a .js file (overrides config createExtension)'],
|
|
11
12
|
['--ts', 'Force a .ts file (overrides config createExtension)'],
|
|
12
13
|
['--template <path>', 'Use a custom template file'],
|
|
14
|
+
['--background', 'Create a background migration (export const background)'],
|
|
13
15
|
],
|
|
14
16
|
// Writing a file needs no database — no spinner, no pre-connect.
|
|
15
17
|
spinner: false,
|
|
16
18
|
run: async (migrator, opts, [name]) => {
|
|
17
19
|
// Tri-state: explicit flag wins; otherwise leave undefined so config decides.
|
|
18
20
|
const js = opts.ts ? false : opts.js ? true : undefined;
|
|
21
|
+
if (opts.background && opts.template) {
|
|
22
|
+
throw new ConfigInvalidError('--background and --template cannot be combined');
|
|
23
|
+
}
|
|
19
24
|
const path = await migrator.create(name, {
|
|
20
25
|
...(js !== undefined ? { js } : {}),
|
|
21
26
|
...(opts.template ? { template: opts.template } : {}),
|
|
27
|
+
...(opts.background ? { background: true } : {}),
|
|
22
28
|
});
|
|
23
29
|
return { path };
|
|
24
30
|
},
|
package/src/cli/exit-codes.js
CHANGED
|
@@ -41,6 +41,12 @@ const EXIT_CODES = {
|
|
|
41
41
|
QUEUE_JOB_FAILED: 26,
|
|
42
42
|
CONVERGE_FAILED: 27,
|
|
43
43
|
COLLECTIONS_DRIFT: 28,
|
|
44
|
+
REVISION_CONFLICT: 29,
|
|
45
|
+
SHAPE_VERSION_UNSUPPORTED: 30,
|
|
46
|
+
BACKGROUND_PENDING: 31,
|
|
47
|
+
BACKGROUND_FAILED: 32,
|
|
48
|
+
BACKGROUND_CONFLICT: 33,
|
|
49
|
+
SANDBOX_REFUSED: 34,
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
module.exports = { EXIT_CODES };
|
package/src/cli/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Command } = require('./args.js');
|
|
2
2
|
const { registerAudit } = require('./commands/audit.js');
|
|
3
|
+
const { registerBackground } = require('./commands/background.js');
|
|
3
4
|
const { registerBaseline } = require('./commands/baseline.js');
|
|
4
5
|
const { registerConverge } = require('./commands/converge.js');
|
|
5
6
|
const { registerCreate } = require('./commands/create.js');
|
|
@@ -44,6 +45,7 @@ function buildProgram() {
|
|
|
44
45
|
registerDown(program);
|
|
45
46
|
registerRedo(program);
|
|
46
47
|
registerConverge(program);
|
|
48
|
+
registerBackground(program);
|
|
47
49
|
registerStatus(program);
|
|
48
50
|
registerList(program);
|
|
49
51
|
registerDryRun(program);
|