@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.
@@ -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
+ };
@@ -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: the plan has a conflict (refused before any write — `context.phase`
247
- * is `'plan'`), or a step failed (`'apply'`). `context.converge` is the
248
- * converge result so far — which steps were applied, which failed, which were
249
- * never reached — and `context.hint`, when present, says what usually fixes
250
- * the server error behind it.
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) {
@@ -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 = { canonical, deepEqual, isPlainObject, regExpIssue, toWire };
204
+ module.exports = {
205
+ assign,
206
+ canonical,
207
+ deepEqual,
208
+ isPlainObject,
209
+ regExpIssue,
210
+ toWire,
211
+ unsendable,
212
+ };
@@ -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 (!['string', 'number', 'boolean'].includes(typeof value)) {
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
 
@@ -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: {