@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,404 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
2
+ const { errorText } = require('../utils/error.js');
3
+ const {
4
+ isSearchIndexReady,
5
+ normalizeLiveSearchIndex,
6
+ searchBuild,
7
+ searchBuildState,
8
+ } = require('./search-index-spec.js');
9
+ const { INDEX_NOT_FOUND, NAMESPACE_NOT_FOUND } = require('./server-info.js');
10
+
11
+ /**
12
+ * Atlas Search for converge: the commands that create, update, list and drop
13
+ * search indexes, the probe that tells whether a server has Search at all,
14
+ * and what the server's errors mean. A mechanism module — it returns
15
+ * outcomes, and converge.js decides what they do to a run.
16
+ *
17
+ * Raw commands rather than the driver's helpers: the helpers exist only from
18
+ * driver 5.6 (`type: 'vectorSearch'` from 6.6), and migronaut supports every
19
+ * driver from 5.0. The commands are the same on Atlas, an Atlas CLI local
20
+ * deployment and a self-managed `mongot`.
21
+ */
22
+
23
+ const INVALID_OPTIONS = 72;
24
+ const UNKNOWN_FIELD = 40415;
25
+
26
+ /**
27
+ * The server saying it has no Atlas Search, by version: SearchNotEnabled
28
+ * (7.3+), CommandNotSupported and "only allowed on MongoDB Atlas" (6.0/7.0),
29
+ * "no such command" and an unknown `$listSearchIndexes` stage (older still).
30
+ */
31
+ const SEARCH_UNAVAILABLE_CODES = new Set([31082, 115, 6047401, 59, 40324]);
32
+ const SEARCH_UNAVAILABLE_MESSAGE = new RegExp(
33
+ [
34
+ 'SearchNotEnabled',
35
+ 'requires additional configuration',
36
+ 'only (?:allowed|supported) (?:on|with) (?:MongoDB )?Atlas',
37
+ "Unrecognized pipeline stage name: '\\$listSearchIndexes'",
38
+ "no such command: '(?:createSearchIndexes|updateSearchIndex|dropSearchIndex)'",
39
+ ].join('|'),
40
+ 'i',
41
+ );
42
+
43
+ /** What to do about a server without Search — shown with every refusal it causes */
44
+ const SEARCH_UNAVAILABLE_HINT =
45
+ 'use Atlas, an Atlas CLI local deployment (the mongodb/mongodb-atlas-local image) or MongoDB ' +
46
+ "8.3+ with mongot — or set onSearchUnavailable: 'skip' to converge everything else";
47
+
48
+ /** The error a self-managed `mongot` gives for a vector index updated without its type */
49
+ const NEEDS_TYPE = /\bmappings\b.*\brequired\b/i;
50
+
51
+ /**
52
+ * Why a vector index could not be updated where `mongot` wants the type the
53
+ * server will not pass on (an Atlas CLI local deployment on MongoDB 8.0
54
+ * refuses `updateSearchIndex` either way) — and the way that works everywhere.
55
+ */
56
+ const VECTOR_UPDATE_HINT =
57
+ 'this server cannot update a vector search index in place (Atlas can) — declare the changed ' +
58
+ 'index under a new name, converge, then remove the old declaration and converge with prune';
59
+
60
+ /** Whether an error from a search command means the server has no Atlas Search */
61
+ function isSearchUnavailable(error) {
62
+ if (SEARCH_UNAVAILABLE_CODES.has(error?.code)) return true;
63
+ if (error?.codeName === 'SearchNotEnabled') return true;
64
+ return SEARCH_UNAVAILABLE_MESSAGE.test(errorText(error));
65
+ }
66
+
67
+ /** What usually fixes the server error behind a failed search index command */
68
+ function searchHint(error) {
69
+ if (isSearchUnavailable(error)) return SEARCH_UNAVAILABLE_HINT;
70
+ const text = errorText(error);
71
+ if (NEEDS_TYPE.test(text)) return VECTOR_UPDATE_HINT;
72
+ if (error?.code === 13) {
73
+ return (
74
+ 'not authorized — search indexes need the createSearchIndexes, updateSearchIndex, ' +
75
+ 'dropSearchIndex and listSearchIndexes actions (readWrite on Atlas)'
76
+ );
77
+ }
78
+ if (/\b(?:limit|maximum|quota|exceed)/i.test(text) && /\bindex/i.test(text)) {
79
+ return (
80
+ 'the cluster has as many search indexes as its tier allows (Free: 3, Flex: 10, search and ' +
81
+ 'vector together) — drop one, or move to a larger tier'
82
+ );
83
+ }
84
+ if (error?.code === 68 || /already exists|duplicate/i.test(text)) {
85
+ return (
86
+ 'a search index with this name already exists, or is still being deleted — converge again ' +
87
+ 'in a moment'
88
+ );
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** A collection's search indexes, as `$listSearchIndexes` reports them — none for a missing one */
94
+ async function listSearchIndexes(db, collection, readOptions) {
95
+ try {
96
+ return await db
97
+ .collection(collection)
98
+ .aggregate([{ $listSearchIndexes: {} }], readOptions)
99
+ .toArray();
100
+ } catch (error) {
101
+ if (error?.code === NAMESPACE_NOT_FOUND) return [];
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /** Whether `version` (`{ major, minor, patch? }`) is at least `major.minor.patch` */
107
+ function atLeast(version, major, minor, patch) {
108
+ if (version === undefined) return false;
109
+ if (version.major !== major) return version.major > major;
110
+ if (version.minor !== minor) return version.minor > minor;
111
+ return (version.patch ?? 0) >= patch;
112
+ }
113
+
114
+ /**
115
+ * Whether the server is wired to a search index manager — the setting every
116
+ * search command checks first. `undefined` when it will not say (Atlas
117
+ * restricts `getParameter`); `false` for an empty setting, or a server that
118
+ * has no such setting at all.
119
+ */
120
+ async function searchManagement(db) {
121
+ if (typeof db.admin !== 'function') return undefined;
122
+ try {
123
+ const reply = await db
124
+ .admin()
125
+ .command({ getParameter: 1, searchIndexManagementHostAndPort: 1 });
126
+ const value = reply?.searchIndexManagementHostAndPort;
127
+ return typeof value === 'string' ? value.length > 0 : undefined;
128
+ } catch (error) {
129
+ // "no option found to get" — with its code on 7.0, without one on 5.0/6.0.
130
+ const unknown = error?.code === INVALID_OPTIONS || /no option found/i.test(errorText(error));
131
+ return unknown ? false : undefined;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Whether the server has Atlas Search, asked by listing the search indexes of
137
+ * `collection` (a declared one, preferably existing — its list is handed back
138
+ * for reuse). Returns `{ available, evidence, reason?, listed? }`:
139
+ *
140
+ * - a server older than 6.0 has no search commands — not asked;
141
+ * - an "unavailable" error is the answer; any other error is rethrown;
142
+ * - a non-empty list, or an empty one from 7.2.1+, means available;
143
+ * - an empty list from an older server proves nothing (a plain `mongod` of
144
+ * that age answered some lists with `[]` instead of an error), so the
145
+ * server's search index manager setting decides — or, where the server
146
+ * will not say, Search is assumed and a refusal at apply time reports it.
147
+ */
148
+ async function probeSearch(db, server, { collection, readOptions }) {
149
+ const { version } = server;
150
+ if (version !== undefined && version.major < 6) {
151
+ return {
152
+ available: false,
153
+ evidence: 'version',
154
+ reason: `MongoDB ${version.major}.${version.minor} has no search index commands`,
155
+ };
156
+ }
157
+ let listed;
158
+ try {
159
+ // Asked of a collection that does not exist yet too: a plain mongod
160
+ // (checked: 7.0, 8.0, 8.2) refuses $listSearchIndexes for want of Search
161
+ // before it looks for the namespace, so an empty list is still an answer.
162
+ listed = await listSearchIndexes(db, collection, readOptions);
163
+ } catch (error) {
164
+ if (!isSearchUnavailable(error)) throw error;
165
+ return { available: false, evidence: 'error', reason: errorText(error) };
166
+ }
167
+ if (listed.length > 0 || atLeast(version, 7, 2, 1)) {
168
+ return { available: true, evidence: 'listed', listed };
169
+ }
170
+ const managed = await searchManagement(db);
171
+ if (managed === false) {
172
+ return {
173
+ available: false,
174
+ evidence: 'parameter',
175
+ reason: 'the server has no search index manager configured',
176
+ };
177
+ }
178
+ return { available: true, evidence: managed ? 'parameter' : 'assumed', listed };
179
+ }
180
+
181
+ /**
182
+ * Update a search index in place. A self-managed `mongot` needs the type of a
183
+ * vector index restated, where Atlas infers it (and the documented command
184
+ * has no type) — so it is sent once more with the type, only on that error.
185
+ */
186
+ async function updateSearchIndex(db, collection, step) {
187
+ const command = { updateSearchIndex: collection, name: step.name, definition: step.definition };
188
+ try {
189
+ await db.command(command);
190
+ } catch (error) {
191
+ if (step.type !== 'vectorSearch' || !NEEDS_TYPE.test(errorText(error))) throw error;
192
+ try {
193
+ await db.command({ ...command, type: step.type });
194
+ } catch (retryError) {
195
+ // A server that does not know the field at all (MongoDB 8.0): the
196
+ // first refusal is the one that says what is wrong.
197
+ if (retryError?.code === UNKNOWN_FIELD || /unknown field/i.test(errorText(retryError))) {
198
+ throw error;
199
+ }
200
+ throw retryError;
201
+ }
202
+ }
203
+ }
204
+
205
+ /** Drop a search index — already gone (or its collection) is the state this step wanted */
206
+ async function dropSearchIndex(db, collection, name) {
207
+ try {
208
+ await db.command({ dropSearchIndex: collection, name });
209
+ } catch (error) {
210
+ if (error?.code === NAMESPACE_NOT_FOUND || error?.code === INDEX_NOT_FOUND) return;
211
+ if (/not found|does not exist/i.test(errorText(error))) return;
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ /** The search index step ops converge.js hands over */
217
+ const SEARCH_STEPS = new Set(['createSearchIndexes', 'updateSearchIndex', 'dropSearchIndex']);
218
+
219
+ /**
220
+ * Carry out one search index step. The server only accepts the work here —
221
+ * a created or updated index builds in the background, so this returns in
222
+ * moments whatever the size of the collection.
223
+ */
224
+ async function runSearchStep(db, collection, step) {
225
+ if (step.op === 'createSearchIndexes') {
226
+ await db.command({ createSearchIndexes: collection, indexes: step.specs });
227
+ } else if (step.op === 'updateSearchIndex') {
228
+ await updateSearchIndex(db, collection, step);
229
+ } else {
230
+ await dropSearchIndex(db, collection, step.name);
231
+ }
232
+ }
233
+
234
+ /** Search index lists read at once while waiting — one per collection */
235
+ const WAIT_READ_CONCURRENCY = 8;
236
+
237
+ /** Failed reads in a row a wait rides out — when each is a connection or failover blip */
238
+ const WAIT_READ_ATTEMPTS = 3;
239
+
240
+ /**
241
+ * Server codes of a read worth trying again: the retryable-read set of the
242
+ * drivers' specification (host unreachable, primary stepped down, shutdown,
243
+ * a time limit…).
244
+ */
245
+ const TRANSIENT_CODES = new Set([
246
+ 6, 7, 89, 91, 134, 189, 262, 9001, 10107, 11600, 11602, 13435, 13436,
247
+ ]);
248
+ const TRANSIENT_NAMES = new Set([
249
+ 'MongoNetworkError',
250
+ 'MongoNetworkTimeoutError',
251
+ 'MongoServerSelectionError',
252
+ 'MongoPoolClearedError',
253
+ ]);
254
+ const TRANSIENT_LABELS = ['ResetPool', 'RetryableWriteError', 'PoolRequstedRetry'];
255
+
256
+ /**
257
+ * Whether a failed read is a blip — a network error, a failover, a node
258
+ * shutting down — rather than an answer: the error itself, or what it wraps.
259
+ * Duck-typed on the driver's error names, codes and labels.
260
+ */
261
+ function isTransientError(error) {
262
+ for (let current = error, depth = 0; current && depth < 3; depth += 1) {
263
+ if (TRANSIENT_NAMES.has(current.name) || TRANSIENT_CODES.has(current.code)) return true;
264
+ if (
265
+ typeof current.hasErrorLabel === 'function' &&
266
+ TRANSIENT_LABELS.some((label) => current.hasErrorLabel(label))
267
+ ) {
268
+ return true;
269
+ }
270
+ current = current.cause;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ /**
276
+ * The pause before the next poll: from a second, half as long again each
277
+ * time, never more than ten seconds — nor more than the budget has left.
278
+ */
279
+ function nextPollDelay(attempt, remainingMs) {
280
+ return Math.max(0, Math.round(Math.min(1000 * 1.5 ** attempt, 10_000, remainingMs)));
281
+ }
282
+
283
+ /** Every target collection's search indexes, read once: `"collection\0name"` → live index */
284
+ async function readAll(collections, read) {
285
+ const live = new Map();
286
+ await mapLimit(collections, WAIT_READ_CONCURRENCY, async (collection) => {
287
+ for (const raw of await read(collection)) {
288
+ const index = normalizeLiveSearchIndex(raw);
289
+ live.set(`${collection}\u0000${index.name}`, index);
290
+ }
291
+ });
292
+ return live;
293
+ }
294
+
295
+ /**
296
+ * Poll until every target serves its latest definition (READY, queryable,
297
+ * nothing newer building — and, for one updated at `sinceVersion`, past that
298
+ * version), one of them FAILED, or `timeoutMs` (undefined: no limit) runs
299
+ * out. A STALE or missing index keeps the wait going: only FAILED ends it
300
+ * early.
301
+ *
302
+ * A target with `touched: false` (one the run did not create or change) that
303
+ * is FAILED or STALE does not hold the wait: converge does not resubmit an
304
+ * unchanged definition, so waiting on it could only time out. It is handed
305
+ * back in `preexisting` instead. One still building is waited for.
306
+ *
307
+ * A read that fails with a blip (see {@link isTransientError}) is retried at
308
+ * the next poll, up to {@link WAIT_READ_ATTEMPTS} in a row; any other failure
309
+ * — or the last of those — is thrown.
310
+ *
311
+ * `targets`: `[{ collection, name, sinceVersion?, touched? }]`;
312
+ * `read(collection)` the collection's `$listSearchIndexes` documents;
313
+ * `beforePoll()` may throw to stop (an abort); `onPoll(live)` sees every
314
+ * poll's `Map` of `"collection\0name"` → normalized live index;
315
+ * `onReadError(error, inARow)` every read failure ridden out. Returns
316
+ * `{ outcome: 'ready' | 'timeout' | 'failed', notReady, preexisting,
317
+ * waitedMs }`, `notReady` holding `{ collection, name, ...build }` (the
318
+ * failed ones first — and, on `'failed'`, in `failed` too).
319
+ */
320
+ async function awaitSearchIndexes({
321
+ targets,
322
+ read,
323
+ timeoutMs,
324
+ sleep,
325
+ now = Date.now,
326
+ beforePoll = () => undefined,
327
+ onPoll = () => undefined,
328
+ onReadError = () => undefined,
329
+ }) {
330
+ const startedAt = now();
331
+ // Each collection once, in the order the targets name them.
332
+ const seen = new Set();
333
+ const collections = [];
334
+ for (const target of targets) {
335
+ if (seen.has(target.collection)) continue;
336
+ seen.add(target.collection);
337
+ collections.push(target.collection);
338
+ }
339
+ const remaining = () => (timeoutMs === undefined ? Infinity : timeoutMs - (now() - startedAt));
340
+ let failedReads = 0;
341
+ for (let attempt = 0; ; attempt++) {
342
+ beforePoll();
343
+ let live;
344
+ try {
345
+ live = await readAll(collections, read);
346
+ failedReads = 0;
347
+ } catch (error) {
348
+ failedReads += 1;
349
+ if (!isTransientError(error) || failedReads >= WAIT_READ_ATTEMPTS || remaining() <= 0) {
350
+ throw error;
351
+ }
352
+ onReadError(error, failedReads);
353
+ await sleep(nextPollDelay(attempt, remaining()));
354
+ continue;
355
+ }
356
+ onPoll(live);
357
+ const failed = [];
358
+ const notReady = [];
359
+ const preexisting = [];
360
+ for (const target of targets) {
361
+ const index = live.get(`${target.collection}\u0000${target.name}`);
362
+ const entry = {
363
+ collection: target.collection,
364
+ name: target.name,
365
+ ...(index ? searchBuild(index) : { status: 'UNKNOWN', queryable: false }),
366
+ };
367
+ const state = index ? searchBuildState(index) : 'building';
368
+ if (target.touched === false && (state === 'failed' || state === 'stale')) {
369
+ preexisting.push(entry);
370
+ } else if (state === 'failed') {
371
+ failed.push(entry);
372
+ } else if (!index || !isSearchIndexReady(index, { sinceVersion: target.sinceVersion })) {
373
+ notReady.push(entry);
374
+ }
375
+ }
376
+ const waitedMs = now() - startedAt;
377
+ if (failed.length > 0) {
378
+ return {
379
+ outcome: 'failed',
380
+ notReady: [...failed, ...notReady],
381
+ failed,
382
+ preexisting,
383
+ waitedMs,
384
+ };
385
+ }
386
+ if (notReady.length === 0) return { outcome: 'ready', notReady, preexisting, waitedMs };
387
+ const remainingMs = remaining();
388
+ if (remainingMs <= 0) return { outcome: 'timeout', notReady, preexisting, waitedMs };
389
+ await sleep(nextPollDelay(attempt, remainingMs));
390
+ }
391
+ }
392
+
393
+ module.exports = {
394
+ SEARCH_STEPS,
395
+ SEARCH_UNAVAILABLE_HINT,
396
+ awaitSearchIndexes,
397
+ isSearchUnavailable,
398
+ isTransientError,
399
+ listSearchIndexes,
400
+ nextPollDelay,
401
+ probeSearch,
402
+ runSearchStep,
403
+ searchHint,
404
+ };