@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,440 @@
1
+ const { setTimeout: sleepFor } = require('node:timers/promises');
2
+ const { ConvergeFailedError, MigronautError } = require('../errors/index.js');
3
+ const { mapLimit } = require('../utils/concurrency.js');
4
+ const { errorText } = require('../utils/error.js');
5
+ const { SERVED_ACTIONS } = require('./converge-plan.js');
6
+ const {
7
+ awaitSearchIndexes,
8
+ listSearchIndexes,
9
+ probeSearch,
10
+ searchHint,
11
+ } = require('./converge-search.js');
12
+ const { searchBuild, searchBuildState } = require('./search-index-spec.js');
13
+ const { READ_CONCURRENCY, READ_OPTIONS } = require('./server-info.js');
14
+
15
+ /**
16
+ * The search half of a converge run: reading the live search indexes (and
17
+ * whether the server has Search at all), the run's word on Search in its
18
+ * result, and the optional wait for the builds — with the log lines, events
19
+ * and metric point that tell it. Orchestration over the same `deps` as
20
+ * converge.js, which calls in at its read, verify, wait and report phases;
21
+ * the commands themselves are converge-search.js's.
22
+ */
23
+
24
+ /**
25
+ * Pauses before the search indexes of a collection are read again, when the
26
+ * first read after a create or an update does not show it yet — the list is
27
+ * eventually consistent. A fixed, short budget: anything still differing
28
+ * after it is reported as unstable, as for regular indexes.
29
+ */
30
+ const SEARCH_SETTLE_DELAYS_MS = [250, 500, 1000];
31
+
32
+ /** Sleep `ms` — less, when `signal` aborts: the caller's next abort check says why */
33
+ async function pause(ms, signal) {
34
+ try {
35
+ await sleepFor(ms, undefined, signal ? { signal } : undefined);
36
+ } catch (error) {
37
+ if (error?.name !== 'AbortError') throw error;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * A row's entry in the result's `notReady` — a declared search index that
43
+ * exists but does not serve its latest definition yet (building, updating,
44
+ * stale or failed) — or undefined. Asked of every row by the report's one
45
+ * pass over them.
46
+ */
47
+ function notReadyEntry(collection, action) {
48
+ if (action.target !== 'searchIndex' || action.build === undefined) return undefined;
49
+ if (!SERVED_ACTIONS.has(action.action)) return undefined;
50
+ if (searchBuildState(action.build) === 'serving') return undefined;
51
+ return { collection, name: action.name, ...action.build };
52
+ }
53
+
54
+ /**
55
+ * The run's word on Search, when search indexes are declared: whether the
56
+ * server has it (and how that was told — `evidence`), the declared indexes
57
+ * not serving yet (`notReady`, gathered by the report), and how a wait for
58
+ * them ended (`wait`).
59
+ */
60
+ function searchSummary(search, notReady) {
61
+ return {
62
+ available: search.available,
63
+ ...(search.evidence !== undefined ? { evidence: search.evidence } : {}),
64
+ notReady,
65
+ ...(search.wait !== undefined ? { wait: search.wait } : {}),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * One line naming every search index whose server-reported options the
71
+ * comparison left out (`found`: `collection.index (paths)`, gathered while
72
+ * the plans were reviewed) — the declarations do not set them, and migronaut
73
+ * knows no default for them (a newer mongot's). Not a problem; worth knowing.
74
+ */
75
+ function warnIgnored(deps, found) {
76
+ if (found.length === 0) return;
77
+ deps.logger.warn(
78
+ `⚠ The server reports search index options the declarations do not set, with no default ` +
79
+ `migronaut knows — left out of the comparison: ${found.join('; ')} — declare them to ` +
80
+ 'manage them',
81
+ deps.fields({ searchIndexes: found.length }),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A search index read that failed for a reason other than "no Search here",
87
+ * in the phase it happened in: `'plan'` (nothing written yet), `'replan'`,
88
+ * `'apply'` (the verify phase after a collection's steps) or `'wait'`.
89
+ */
90
+ function searchReadFailure(error, collection, phase) {
91
+ if (error instanceof MigronautError) return error;
92
+ const mongoCode = typeof error?.code === 'number' ? error.code : undefined;
93
+ const hint = searchHint(error);
94
+ const cause = errorText(error);
95
+ return new ConvergeFailedError(
96
+ `Could not read the search indexes of ${collection}: ${cause}${hint ? ` — ${hint}` : ''}`,
97
+ {
98
+ phase,
99
+ ...(phase === 'wait' ? { reason: 'unreadable' } : {}),
100
+ collection,
101
+ target: 'searchIndex',
102
+ cause,
103
+ ...(mongoCode !== undefined ? { mongoCode } : {}),
104
+ ...(hint ? { hint } : {}),
105
+ },
106
+ { cause: error },
107
+ );
108
+ }
109
+
110
+ /**
111
+ * The search half of the read phase: whether the server has Atlas Search
112
+ * (one probe, cached in `search` for the run), and the live search indexes of
113
+ * every existing collection that declares some. Collections that declare
114
+ * none are never asked — a run without `searchIndexes` makes no search call.
115
+ */
116
+ async function readSearch(deps, server, definitions, live, search) {
117
+ // One pass: the declaring collections that exist as regular ones (a view or
118
+ // a time-series collection is refused by the planner anyway), and the first
119
+ // declaring one that does not exist yet — the probe's fallback.
120
+ const existing = [];
121
+ let missing;
122
+ for (const [position, definition] of definitions.entries()) {
123
+ if (definition.searchIndexes === undefined) continue;
124
+ const state = live[position];
125
+ if (state.exists && state.type === 'collection') existing.push(position);
126
+ else if (!state.exists && missing === undefined) missing = position;
127
+ }
128
+ const target = existing[0] ?? missing;
129
+ if (target === undefined) return;
130
+ const targetName = definitions[target].name;
131
+ let probe;
132
+ try {
133
+ probe = await probeSearch(deps.db, server, {
134
+ collection: targetName,
135
+ readOptions: READ_OPTIONS,
136
+ });
137
+ } catch (error) {
138
+ throw searchReadFailure(error, targetName, 'plan');
139
+ }
140
+ search.available = probe.available;
141
+ search.evidence = probe.evidence;
142
+ if (!probe.available) {
143
+ deps.logger.debug(
144
+ `Atlas Search is not available: ${probe.reason}`,
145
+ deps.fields({ evidence: probe.evidence }),
146
+ );
147
+ warnSkipping(deps, definitions, search);
148
+ return;
149
+ }
150
+ if (probe.evidence === 'assumed') {
151
+ // Worth seeing when the run is about to wait on what it assumed.
152
+ deps.logger[search.waitRequested ? 'info' : 'debug'](
153
+ 'Atlas Search assumed available: the server listed no search index and would not say more',
154
+ deps.fields({ evidence: probe.evidence }),
155
+ );
156
+ }
157
+ await mapLimit(existing, READ_CONCURRENCY, async (position) => {
158
+ if (position === target) {
159
+ live[position].searchIndexes = probe.listed;
160
+ return;
161
+ }
162
+ live[position].searchIndexes = await readSearchIndexes(
163
+ deps,
164
+ definitions[position].name,
165
+ 'plan',
166
+ );
167
+ });
168
+ }
169
+
170
+ async function readSearchIndexes(deps, name, phase) {
171
+ try {
172
+ return await listSearchIndexes(deps.db, name, READ_OPTIONS);
173
+ } catch (error) {
174
+ throw searchReadFailure(error, name, phase);
175
+ }
176
+ }
177
+
178
+ /** In skip mode, one line for every declared search index the run will not touch */
179
+ function warnSkipping(deps, definitions, search) {
180
+ if (search.onUnavailable !== 'skip') return;
181
+ let count = 0;
182
+ for (const definition of definitions) count += definition.searchIndexes?.length ?? 0;
183
+ if (count === 0) return;
184
+ deps.logger.warn(
185
+ `⚠ Atlas Search is not available on this server — skipping ${count} declared search ` +
186
+ "index(es) (onSearchUnavailable: 'skip')",
187
+ deps.fields({ skipped: count }),
188
+ );
189
+ }
190
+
191
+ /** The build state each search index row now has, from the verify phase's read */
192
+ function refreshBuilds(rows, fresh) {
193
+ const builds = new Map();
194
+ for (const action of fresh) {
195
+ if (action.target === 'searchIndex' && action.build !== undefined) {
196
+ builds.set(action.name, action.build);
197
+ }
198
+ }
199
+ for (const row of rows) {
200
+ if (row.target === 'searchIndex' && builds.has(row.name)) row.build = builds.get(row.name);
201
+ }
202
+ }
203
+
204
+ /** How often a wait for search indexes says it is still waiting */
205
+ const WAIT_PROGRESS_MS = 30_000;
206
+
207
+ /**
208
+ * Every declared search index a wait is for: the ones that exist (or were
209
+ * just created) — with, for one this run updated, the definition version the
210
+ * update started from, so the old definition reading READY does not count —
211
+ * and whether this run created or changed it (`touched`): a FAILED or STALE
212
+ * index the run did not touch does not hold the wait (see awaitSearchIndexes).
213
+ */
214
+ function waitTargets(run) {
215
+ const { definitions, plans, result } = run;
216
+ const targets = [];
217
+ for (const [position, definition] of definitions.entries()) {
218
+ if (definition.searchIndexes === undefined) continue;
219
+ // The collection's search index rows and update steps by name, read once —
220
+ // not searched again for every declared index.
221
+ const rows = new Map();
222
+ for (const action of result.collections[position].actions) {
223
+ if (action.target === 'searchIndex') rows.set(action.name, action);
224
+ }
225
+ const updates = new Map();
226
+ for (const step of plans[position].steps) {
227
+ if (step.op === 'updateSearchIndex') updates.set(step.name, step);
228
+ }
229
+ for (const declared of definition.searchIndexes) {
230
+ const row = rows.get(declared.name);
231
+ if (!row || !SERVED_ACTIONS.has(row.action)) continue;
232
+ const update = updates.get(declared.name);
233
+ targets.push({
234
+ collection: definition.name,
235
+ name: declared.name,
236
+ ...(update?.sinceVersion !== undefined ? { sinceVersion: update.sinceVersion } : {}),
237
+ touched: row.action !== 'unchanged',
238
+ });
239
+ }
240
+ }
241
+ return targets;
242
+ }
243
+
244
+ /** `movies.default (BUILDING), shows.plot (FAILED: …)` */
245
+ function describeNotReady(notReady) {
246
+ return notReady
247
+ .map((index) => {
248
+ const state = searchBuildState(index);
249
+ const note =
250
+ state === 'updating' ? ', updating' : state === 'stale' ? ', not replicating' : '';
251
+ return (
252
+ `${index.collection}.${index.name} (${index.status}${note}` +
253
+ `${index.message ? `: ${index.message}` : ''})`
254
+ );
255
+ })
256
+ .join(', ');
257
+ }
258
+
259
+ /** Why a wait ran out, and what to do — a STALE index will not get there by waiting longer */
260
+ function timeoutAdvice(notReady) {
261
+ if (notReady.every((index) => searchBuildState(index) === 'stale')) {
262
+ return 'a STALE index is queryable but no longer replicating from the collection — see troubleshooting';
263
+ }
264
+ return 'the server goes on building; converge again to wait more, or raise searchIndexWaitTimeoutMs';
265
+ }
266
+
267
+ /**
268
+ * The wait phase (`waitForSearchIndexes`): after every collection's steps,
269
+ * poll until each declared search index serves its declaration — or fail the
270
+ * run on a FAILED build or when the budget runs out. It only reads, so the
271
+ * migration lock is given up first (`deps.releaseLock`, when the run holds
272
+ * one): other runs — the next deploy, a queue's jobs — need not wait out a
273
+ * build. The run itself goes on until the wait ends; an abort (a stop) ends
274
+ * the wait between polls, and cuts the pause before the next one short.
275
+ */
276
+ async function waitPhase(run, signal) {
277
+ const { deps, result, search, wait } = run;
278
+ if (!wait.enabled || !search.declared || !search.available) return;
279
+ const targets = waitTargets(run);
280
+ if (targets.length === 0) return;
281
+ const now = deps.now ?? Date.now;
282
+ const startedAt = now();
283
+ const lockReleased = typeof deps.releaseLock === 'function' && (await deps.releaseLock());
284
+ const limit =
285
+ wait.timeoutMs === undefined ? '' : ` (up to ${Math.round(wait.timeoutMs / 1000)}s)`;
286
+ deps.logger.info(
287
+ `… Waiting for ${targets.length} search index(es) to become queryable${limit}` +
288
+ (lockReleased ? ' — the migration lock is released meanwhile' : ''),
289
+ deps.fields({ searchIndexes: targets.length, timeoutMs: wait.timeoutMs, lockReleased }),
290
+ );
291
+ deps.emit('converge:wait', {
292
+ status: 'started',
293
+ searchIndexes: targets.length,
294
+ lockReleased,
295
+ ...(wait.timeoutMs !== undefined ? { timeoutMs: wait.timeoutMs } : {}),
296
+ });
297
+ let reportedAt = startedAt;
298
+ let ended = 'aborted';
299
+ let notReady = [];
300
+ const sleep = deps.sleep ?? pause;
301
+ try {
302
+ const outcome = await awaitSearchIndexes({
303
+ targets,
304
+ read: (collection) => readSearchIndexes(deps, collection, 'wait'),
305
+ timeoutMs: wait.timeoutMs,
306
+ sleep: (ms) => sleep(ms, signal),
307
+ now,
308
+ // An abort ends the wait here; runConverge attaches the result to it.
309
+ beforePoll: () => deps.assertNotAborted(signal),
310
+ onReadError: (error, inARow) => {
311
+ deps.logger.warn(
312
+ `⚠ Could not read the search indexes (${inARow} in a row) — trying again: ` +
313
+ errorText(error),
314
+ deps.fields({ error: errorText(error), consecutiveFailures: inARow }),
315
+ );
316
+ },
317
+ onPoll: (live) => {
318
+ for (const collection of result.collections) {
319
+ for (const row of collection.actions) {
320
+ const index = live.get(`${collection.name}\u0000${row.name}`);
321
+ if (row.target === 'searchIndex' && index) row.build = searchBuild(index);
322
+ }
323
+ }
324
+ if (now() - reportedAt < WAIT_PROGRESS_MS) return;
325
+ reportedAt = now();
326
+ const waitedMs = reportedAt - startedAt;
327
+ deps.logger.info(
328
+ `… Still waiting for search indexes [${Math.round(waitedMs / 1000)}s]`,
329
+ deps.fields({ waitedMs }),
330
+ );
331
+ deps.emit('converge:wait', { status: 'progress', searchIndexes: targets.length, waitedMs });
332
+ },
333
+ });
334
+ ended = outcome.outcome;
335
+ notReady = outcome.notReady;
336
+ settleWait(deps, targets, outcome, wait, result);
337
+ } catch (error) {
338
+ if (error instanceof ConvergeFailedError && error.context?.reason === 'unreadable') {
339
+ ended = 'unreadable';
340
+ }
341
+ throw error;
342
+ } finally {
343
+ const waitedMs = now() - startedAt;
344
+ search.wait = { outcome: ended, waitedMs };
345
+ deps.recordSearchWait?.(waitedMs, ended);
346
+ deps.emit('converge:wait', {
347
+ status: ended,
348
+ searchIndexes: targets.length,
349
+ waitedMs,
350
+ ...(notReady.length > 0 ? { notReady } : {}),
351
+ });
352
+ }
353
+ }
354
+
355
+ /** A wait that ended: its closing lines — or, unless every index is ready, the error */
356
+ function settleWait(deps, targets, outcome, wait, result) {
357
+ if (outcome.preexisting.length > 0) {
358
+ deps.logger.info(
359
+ `• Not waiting for ${outcome.preexisting.length} search index(es) this run did not ` +
360
+ `change, which cannot get there by waiting: ${describeNotReady(outcome.preexisting)}`,
361
+ deps.fields({ preexisting: outcome.preexisting.length }),
362
+ );
363
+ }
364
+ if (outcome.outcome === 'ready') {
365
+ const ready = targets.length - outcome.preexisting.length;
366
+ deps.logger.info(
367
+ `✔ Search index(es) queryable: ${ready} [${outcome.waitedMs}ms]`,
368
+ deps.fields({ searchIndexes: ready, waitedMs: outcome.waitedMs }),
369
+ );
370
+ return;
371
+ }
372
+ const failed = outcome.outcome === 'failed';
373
+ throw new ConvergeFailedError(
374
+ failed
375
+ ? `Search index build failed: ${describeNotReady(outcome.failed)} — fix the definition or the data, then converge again`
376
+ : `Search index(es) not queryable after ${Math.round(outcome.waitedMs / 1000)}s: ` +
377
+ `${describeNotReady(outcome.notReady)} — ${timeoutAdvice(outcome.notReady)}`,
378
+ {
379
+ phase: 'wait',
380
+ reason: failed ? 'failed' : 'timeout',
381
+ notReady: outcome.notReady,
382
+ waitedMs: outcome.waitedMs,
383
+ ...(wait.timeoutMs !== undefined ? { timeoutMs: wait.timeoutMs } : {}),
384
+ converge: result,
385
+ },
386
+ );
387
+ }
388
+
389
+ /** Closing lines about search indexes that do not serve their declaration yet */
390
+ function reportNotReady(deps, result) {
391
+ // One pass sorts them: the building ones into one line, the stale and the
392
+ // failed into a warning each — said after that line.
393
+ const building = [];
394
+ const warnings = [];
395
+ for (const index of result.search?.notReady ?? []) {
396
+ const state = searchBuildState(index);
397
+ const detail = index.message ? `: ${index.message}` : '';
398
+ if (state === 'stale') {
399
+ warnings.push([
400
+ index,
401
+ `is STALE — queryable, but no longer replicating from the collection, so its results ` +
402
+ `may be out of date${detail}`,
403
+ ]);
404
+ } else if (state === 'failed') {
405
+ warnings.push([
406
+ index,
407
+ `failed to build${detail} — converge does not resubmit an unchanged definition; fix ` +
408
+ 'the definition or the data',
409
+ ]);
410
+ } else {
411
+ building.push(`${index.collection}.${index.name}`);
412
+ }
413
+ }
414
+ if (building.length > 0) {
415
+ deps.logger.info(
416
+ `• ${building.length} search index(es) still building on the server: ` +
417
+ `${building.join(', ')} — waitForSearchIndexes (CLI: --wait-search) waits for them`,
418
+ deps.fields({ building: building.length }),
419
+ );
420
+ }
421
+ for (const [index, what] of warnings) {
422
+ deps.logger.warn(
423
+ `⚠ ${index.collection}: search index "${index.name}" ${what}`,
424
+ deps.fields({ collection: index.collection, searchIndex: index.name }),
425
+ );
426
+ }
427
+ }
428
+
429
+ module.exports = {
430
+ SEARCH_SETTLE_DELAYS_MS,
431
+ notReadyEntry,
432
+ pause,
433
+ readSearch,
434
+ readSearchIndexes,
435
+ refreshBuilds,
436
+ reportNotReady,
437
+ searchSummary,
438
+ waitPhase,
439
+ warnIgnored,
440
+ };