@alexify/migronaut 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -0
- package/README.md +35 -8
- package/bullmq.d.ts +16 -1
- package/index.d.ts +266 -13
- package/migronaut.schema.json +57 -1
- package/package.json +1 -1
- package/src/bullmq/processor.js +25 -1
- package/src/bullmq/producer.js +17 -14
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +88 -3
- package/src/core/collections.js +50 -26
- package/src/core/config.js +31 -1
- package/src/core/converge-plan.js +260 -57
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +347 -190
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +50 -12
- package/src/core/migrator.js +47 -14
- package/src/core/options.js +16 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +9 -5
- package/src/utils/canonical.js +34 -1
- package/src/utils/telemetry.js +18 -1
- package/src/utils/template.js +7 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What converge and audit read about the server, and how: the read options
|
|
3
|
+
* forced onto every read, the pace of reading many collections, the server's
|
|
4
|
+
* version and topology, and the error codes for a namespace or an index that
|
|
5
|
+
* is not there. Mechanism only — no logger, no decisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read options forced onto both reads: the primary (a secondary may not have
|
|
10
|
+
* an index build yet), and BSON values as plain JavaScript — an injected
|
|
11
|
+
* client configured with `promoteValues: false` or `useBigInt64: true` would
|
|
12
|
+
* otherwise hand back `Int32` objects or `1n`, and everything would compare as
|
|
13
|
+
* changed.
|
|
14
|
+
*/
|
|
15
|
+
const READ_OPTIONS = Object.freeze({
|
|
16
|
+
readPreference: 'primary',
|
|
17
|
+
promoteLongs: true,
|
|
18
|
+
promoteValues: true,
|
|
19
|
+
useBigInt64: false,
|
|
20
|
+
bsonRegExp: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** listIndexes calls in flight while reading many collections — a pace, not a pool */
|
|
24
|
+
const READ_CONCURRENCY = 8;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the server is: a mongos in front of shards (its shard keys matter to
|
|
28
|
+
* prune), and its version (what it can change in place). Best-effort — a
|
|
29
|
+
* server that refuses to say gets the conservative answer: no in-place
|
|
30
|
+
* extras, no shard-key handling.
|
|
31
|
+
*/
|
|
32
|
+
async function readServer(db) {
|
|
33
|
+
const server = { mongos: false, version: undefined };
|
|
34
|
+
if (typeof db.admin !== 'function') return server;
|
|
35
|
+
try {
|
|
36
|
+
const hello = await db.admin().command({ hello: 1 });
|
|
37
|
+
server.mongos = hello?.msg === 'isdbgrid';
|
|
38
|
+
} catch {
|
|
39
|
+
// Unknown — treated as a replica set or standalone.
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const info = await db.admin().command({ buildInfo: 1 });
|
|
43
|
+
const [major, minor, patch] = Array.isArray(info?.versionArray) ? info.versionArray : [];
|
|
44
|
+
if (Number.isInteger(major) && Number.isInteger(minor)) {
|
|
45
|
+
server.version = { major, minor, ...(Number.isInteger(patch) ? { patch } : {}) };
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Unknown version: only the always-available in-place changes.
|
|
49
|
+
}
|
|
50
|
+
return server;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NAMESPACE_NOT_FOUND = 26;
|
|
54
|
+
|
|
55
|
+
const INDEX_NOT_FOUND = 27;
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
INDEX_NOT_FOUND,
|
|
59
|
+
NAMESPACE_NOT_FOUND,
|
|
60
|
+
READ_CONCURRENCY,
|
|
61
|
+
READ_OPTIONS,
|
|
62
|
+
readServer,
|
|
63
|
+
};
|
package/src/errors/index.js
CHANGED
|
@@ -243,11 +243,15 @@ class QueueJobFailedError extends MigronautError {
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* Thrown by `converge` when the database cannot be brought to the declared
|
|
246
|
-
* state:
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
246
|
+
* state. `context.phase` says where it stopped: `'plan'` (a conflict refused
|
|
247
|
+
* the run before any write), `'replan'` (a collection changed while the run
|
|
248
|
+
* was under way), `'apply'` (a step failed) or `'wait'` (the search index
|
|
249
|
+
* builds did not finish — `context.reason`: `'failed'`, `'timeout'` or
|
|
250
|
+
* `'unreadable'`). A search index list that could not be read is reported in
|
|
251
|
+
* the phase that read it. `context.converge` is the converge result so far —
|
|
252
|
+
* which steps were applied, which failed, which were never reached — and
|
|
253
|
+
* `context.hint`, when present, says what usually fixes the server error
|
|
254
|
+
* behind it.
|
|
251
255
|
*/
|
|
252
256
|
class ConvergeFailedError extends MigronautError {
|
|
253
257
|
constructor(message, context, options) {
|
package/src/utils/canonical.js
CHANGED
|
@@ -100,6 +100,31 @@ function regExpIssue(value, seen = new Set()) {
|
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Why `value` cannot be sent as declared — a function, a symbol, a cycle, or a
|
|
105
|
+
* RegExp the driver would change (see {@link regExpIssue}) — or null. Walks
|
|
106
|
+
* plain objects and arrays, the shapes a declaration is written in.
|
|
107
|
+
*/
|
|
108
|
+
function unsendable(value, seen = new Set()) {
|
|
109
|
+
if (seen.size === 0) {
|
|
110
|
+
const issue = regExpIssue(value);
|
|
111
|
+
if (issue) return issue;
|
|
112
|
+
}
|
|
113
|
+
const type = typeof value;
|
|
114
|
+
if (type === 'function') return 'must not contain functions';
|
|
115
|
+
if (type === 'symbol') return 'must not contain symbols';
|
|
116
|
+
if (value === null || type !== 'object') return null;
|
|
117
|
+
if (seen.has(value)) return 'must not contain circular references';
|
|
118
|
+
seen.add(value);
|
|
119
|
+
const items = Array.isArray(value) ? value : isPlainObject(value) ? Object.values(value) : [];
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const reason = unsendable(item, seen);
|
|
122
|
+
if (reason) return reason;
|
|
123
|
+
}
|
|
124
|
+
seen.delete(value);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
/**
|
|
104
129
|
* Assign without invoking setters: a key named `__proto__` (JSON.parse makes
|
|
105
130
|
* one an own property) must stay a key, not replace the object's prototype —
|
|
@@ -176,4 +201,12 @@ function toWire(value) {
|
|
|
176
201
|
return out;
|
|
177
202
|
}
|
|
178
203
|
|
|
179
|
-
module.exports = {
|
|
204
|
+
module.exports = {
|
|
205
|
+
assign,
|
|
206
|
+
canonical,
|
|
207
|
+
deepEqual,
|
|
208
|
+
isPlainObject,
|
|
209
|
+
regExpIssue,
|
|
210
|
+
toWire,
|
|
211
|
+
unsendable,
|
|
212
|
+
};
|
package/src/utils/telemetry.js
CHANGED
|
@@ -28,6 +28,7 @@ const ATTRIBUTES = {
|
|
|
28
28
|
LOCK_SKIPPED: 'migronaut.lock.skipped',
|
|
29
29
|
LOCK_LOST_REASON: 'migronaut.lock.lost_reason',
|
|
30
30
|
LOCK_WAIT_OUTCOME: 'migronaut.lock.wait.outcome',
|
|
31
|
+
SEARCH_WAIT_OUTCOME: 'migronaut.converge.search.wait.outcome',
|
|
31
32
|
MIGRATION_NAME: 'migronaut.migration.name',
|
|
32
33
|
MIGRATION_DIRECTION: 'migronaut.migration.direction',
|
|
33
34
|
MIGRATION_BATCH: 'migronaut.migration.batch',
|
|
@@ -46,6 +47,7 @@ const METRICS = {
|
|
|
46
47
|
LOCK_WAIT_DURATION: 'migronaut.lock.wait.duration',
|
|
47
48
|
LOCK_REFUSED: 'migronaut.lock.refused',
|
|
48
49
|
LOCK_LOST: 'migronaut.lock.lost',
|
|
50
|
+
SEARCH_WAIT_DURATION: 'migronaut.converge.search.wait.duration',
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -130,6 +132,9 @@ function failureText(error) {
|
|
|
130
132
|
|
|
131
133
|
/** The parts of `telemetry` — anything else in it is a typo, mentioned at debug level */
|
|
132
134
|
const TELEMETRY_KEYS = Object.freeze(['tracer', 'meter', 'attributes']);
|
|
135
|
+
|
|
136
|
+
/** The types a static attribute's value may have */
|
|
137
|
+
const ATTRIBUTE_VALUE_TYPES = new Set(['string', 'number', 'boolean']);
|
|
133
138
|
/** Static attributes are dimensions: a handful at most */
|
|
134
139
|
const MAX_STATIC_ATTRIBUTES = 20;
|
|
135
140
|
|
|
@@ -184,7 +189,7 @@ function telemetryIssues(telemetry) {
|
|
|
184
189
|
}
|
|
185
190
|
for (const key of keys) {
|
|
186
191
|
const value = attributes[key];
|
|
187
|
-
if (!
|
|
192
|
+
if (!ATTRIBUTE_VALUE_TYPES.has(typeof value)) {
|
|
188
193
|
issues.push({
|
|
189
194
|
path: `telemetry.attributes.${key}`,
|
|
190
195
|
message: 'must be a string, a number or a boolean',
|
|
@@ -329,6 +334,10 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
329
334
|
'{refusal}',
|
|
330
335
|
);
|
|
331
336
|
const lockLost = counter(METRICS.LOCK_LOST, 'Migration locks lost mid-run', '{loss}');
|
|
337
|
+
const searchWaitDuration = histogram(
|
|
338
|
+
METRICS.SEARCH_WAIT_DURATION,
|
|
339
|
+
'Time a converge waited for its search index builds, by how the wait ended',
|
|
340
|
+
);
|
|
332
341
|
|
|
333
342
|
// Durations are measured in milliseconds everywhere in migronaut and
|
|
334
343
|
// reported in seconds, the unit OpenTelemetry's conventions settle on.
|
|
@@ -374,6 +383,14 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
374
383
|
lockLost() {
|
|
375
384
|
increment(lockLost);
|
|
376
385
|
},
|
|
386
|
+
/**
|
|
387
|
+
* A converge's wait for search index builds ended — `outcome` is
|
|
388
|
+
* `'ready'`, `'failed'`, `'timeout'`, `'unreadable'` or `'aborted'`. One
|
|
389
|
+
* point per wait, however many polls.
|
|
390
|
+
*/
|
|
391
|
+
searchWaited({ waitedMs, outcome }) {
|
|
392
|
+
record(searchWaitDuration, waitedMs, { [ATTRIBUTES.SEARCH_WAIT_OUTCOME]: outcome });
|
|
393
|
+
},
|
|
377
394
|
};
|
|
378
395
|
}
|
|
379
396
|
|
package/src/utils/template.js
CHANGED
|
@@ -358,6 +358,13 @@ function configBody(values, createExtension) {
|
|
|
358
358
|
// collectionsDir: './collections',
|
|
359
359
|
// Converge at the end of every bulk \`migronaut up\`.
|
|
360
360
|
// convergeAfterUp: false,
|
|
361
|
+
// Search indexes declared for a server without Atlas Search: refuse the
|
|
362
|
+
// converge ('fail'), or converge everything else and skip them ('skip').
|
|
363
|
+
// onSearchUnavailable: 'fail',
|
|
364
|
+
// Hold converge until every declared search index is queryable — for at
|
|
365
|
+
// most searchIndexWaitTimeoutMs (10 minutes by default).
|
|
366
|
+
// waitForSearchIndexes: false,
|
|
367
|
+
// searchIndexWaitTimeoutMs: 600000,
|
|
361
368
|
|
|
362
369
|
// ── Lifecycle hooks (code only — not available in JSON config) ──
|
|
363
370
|
// hooks: {
|