@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
package/src/core/index-spec.js
CHANGED
|
@@ -53,6 +53,9 @@ const INDEX_KEYS = new Set([
|
|
|
53
53
|
...IGNORED_OPTIONS,
|
|
54
54
|
]);
|
|
55
55
|
|
|
56
|
+
/** Every option an index is normalized with — built once, read per index */
|
|
57
|
+
const NORMALIZED_OPTIONS = Object.freeze([...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]);
|
|
58
|
+
|
|
56
59
|
const BOOLEAN_OPTIONS = new Set(['unique', 'sparse', 'hidden', 'background']);
|
|
57
60
|
const OBJECT_OPTIONS = new Set([
|
|
58
61
|
'partialFilterExpression',
|
|
@@ -98,16 +101,20 @@ function keyIssues(key, report) {
|
|
|
98
101
|
return null;
|
|
99
102
|
}
|
|
100
103
|
let usable = true;
|
|
104
|
+
let integerLike = false;
|
|
101
105
|
for (const [field, direction] of entries) {
|
|
102
106
|
if (typeof field !== 'string' || field.length === 0) {
|
|
103
107
|
report('field names must be non-empty strings');
|
|
104
108
|
usable = false;
|
|
105
|
-
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!integerLike && INTEGER_LIKE.test(field)) integerLike = true;
|
|
112
|
+
if (!DIRECTIONS.has(direction)) {
|
|
106
113
|
report(`direction of "${field}" must be 1, -1, 'text', 'hashed', '2d' or '2dsphere'`);
|
|
107
114
|
usable = false;
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
|
-
if (usable && entries.length > 1 &&
|
|
117
|
+
if (usable && entries.length > 1 && integerLike) {
|
|
111
118
|
if (!(key instanceof Map)) {
|
|
112
119
|
report(
|
|
113
120
|
'has an integer-like field name — JavaScript reorders such keys, so declare this key ' +
|
|
@@ -145,10 +152,14 @@ function indexIssues(index, path) {
|
|
|
145
152
|
if (!INDEX_KEYS.has(key)) report(key)('is not a supported index option');
|
|
146
153
|
}
|
|
147
154
|
const entries = keyIssues(index.key, report('key'));
|
|
148
|
-
|
|
155
|
+
let isText = false;
|
|
149
156
|
// `wildcardProjection` belongs to an all-fields wildcard (`$**`, compound or
|
|
150
157
|
// not) — the server refuses it on a path wildcard such as `a.$**`.
|
|
151
|
-
|
|
158
|
+
let isAllFieldsWildcard = false;
|
|
159
|
+
for (const [field, direction] of entries ?? []) {
|
|
160
|
+
if (direction === TEXT) isText = true;
|
|
161
|
+
if (field === '$**') isAllFieldsWildcard = true;
|
|
162
|
+
}
|
|
152
163
|
|
|
153
164
|
if (index.name !== undefined) {
|
|
154
165
|
if (typeof index.name !== 'string' || index.name.length === 0) {
|
|
@@ -209,23 +220,23 @@ function indexIssues(index, path) {
|
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
/**
|
|
212
|
-
* The key as the server stores it
|
|
213
|
-
*
|
|
214
|
-
* first text field, and move
|
|
223
|
+
* The key as the server stores it, and whether it is a text index — in one
|
|
224
|
+
* pass. A text index is not stored under its fields: they collapse into
|
|
225
|
+
* `_fts: 'text', _ftsx: 1` at the position of the first text field, and move
|
|
226
|
+
* into `weights`.
|
|
215
227
|
*/
|
|
216
228
|
function serverKeyOf(entries) {
|
|
217
|
-
if (!entries.some(([, direction]) => direction === TEXT)) return entries;
|
|
218
229
|
const out = [];
|
|
219
|
-
let
|
|
230
|
+
let isText = false;
|
|
220
231
|
for (const entry of entries) {
|
|
221
232
|
if (entry[1] !== TEXT) {
|
|
222
233
|
out.push(entry);
|
|
223
|
-
} else if (!
|
|
234
|
+
} else if (!isText) {
|
|
224
235
|
out.push(['_fts', TEXT], ['_ftsx', 1]);
|
|
225
|
-
|
|
236
|
+
isText = true;
|
|
226
237
|
}
|
|
227
238
|
}
|
|
228
|
-
return out;
|
|
239
|
+
return { serverKey: isText ? out : entries, isText };
|
|
229
240
|
}
|
|
230
241
|
|
|
231
242
|
/** Text fields at weight 1, then whatever the declaration weighs differently */
|
|
@@ -243,10 +254,10 @@ function textWeights(entries, declared) {
|
|
|
243
254
|
*/
|
|
244
255
|
function normalizeDeclaredIndex(index) {
|
|
245
256
|
const entries = keyEntries(index.key);
|
|
246
|
-
const
|
|
257
|
+
const { serverKey, isText } = serverKeyOf(entries);
|
|
247
258
|
const name = index.name ?? defaultIndexName(entries);
|
|
248
259
|
const options = {};
|
|
249
|
-
for (const option of
|
|
260
|
+
for (const option of NORMALIZED_OPTIONS) {
|
|
250
261
|
const value = index[option];
|
|
251
262
|
if (value === undefined) continue;
|
|
252
263
|
// `false` is the server default; sending it would only make the stored
|
|
@@ -258,7 +269,7 @@ function normalizeDeclaredIndex(index) {
|
|
|
258
269
|
return {
|
|
259
270
|
name,
|
|
260
271
|
entries,
|
|
261
|
-
serverKey
|
|
272
|
+
serverKey,
|
|
262
273
|
isText,
|
|
263
274
|
options,
|
|
264
275
|
// The key travels as a Map: the field order is the index, and the driver
|
|
@@ -271,7 +282,7 @@ function normalizeDeclaredIndex(index) {
|
|
|
271
282
|
function normalizeLiveIndex(raw) {
|
|
272
283
|
const entries = Object.entries(raw.key ?? {});
|
|
273
284
|
const options = {};
|
|
274
|
-
for (const option of
|
|
285
|
+
for (const option of NORMALIZED_OPTIONS) {
|
|
275
286
|
const value = raw[option];
|
|
276
287
|
if (value === undefined) continue;
|
|
277
288
|
if (BOOLEAN_OPTIONS.has(option)) {
|
package/src/core/lock.js
CHANGED
|
@@ -229,10 +229,18 @@ class MigrationLock {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
|
-
* Run `fn(signal)` while holding the migration lock. The lock is
|
|
233
|
-
* released in a `finally` block. While `fn` runs, a heartbeat renews the
|
|
234
|
-
* every `ttlMs/2` so a migration that takes longer than the TTL never lets
|
|
235
|
-
* lock go stale and get reclaimed by another process.
|
|
232
|
+
* Run `fn(signal, control)` while holding the migration lock. The lock is
|
|
233
|
+
* always released in a `finally` block. While `fn` runs, a heartbeat renews the
|
|
234
|
+
* lock every `ttlMs/2` so a migration that takes longer than the TTL never lets
|
|
235
|
+
* its lock go stale and get reclaimed by another process.
|
|
236
|
+
*
|
|
237
|
+
* `control.release()` gives the lock up before `fn` returns — once, for a
|
|
238
|
+
* tail of work that only reads (converge waiting for search index builds):
|
|
239
|
+
* the heartbeat stops, the lock document goes, `onLockReleased({ early: true })`
|
|
240
|
+
* fires, and the signal no longer aborts on a lost lock. It resolves to
|
|
241
|
+
* whether the lock was released: one that fails is only warned about (the
|
|
242
|
+
* lock frees itself once its TTL runs out) and tried again at the end. With
|
|
243
|
+
* `noLock` there is nothing to release (`false`).
|
|
236
244
|
*
|
|
237
245
|
* If the lock is nonetheless lost — another process reclaimed it, or the
|
|
238
246
|
* heartbeat cannot reach the database — the `signal` is aborted with a
|
|
@@ -254,7 +262,7 @@ async function runWithLock(lock, options, fn) {
|
|
|
254
262
|
// `skipped: true` tells them apart from a real lock.
|
|
255
263
|
options.onLockAcquired?.({ skipped: true });
|
|
256
264
|
try {
|
|
257
|
-
return await fn(controller.signal);
|
|
265
|
+
return await fn(controller.signal, { release: async () => false });
|
|
258
266
|
} finally {
|
|
259
267
|
options.onLockReleased?.({ skipped: true });
|
|
260
268
|
}
|
|
@@ -352,23 +360,53 @@ async function runWithLock(lock, options, fn) {
|
|
|
352
360
|
}, intervalMs);
|
|
353
361
|
heartbeat.unref?.();
|
|
354
362
|
|
|
363
|
+
const stopHeartbeat = async () => {
|
|
364
|
+
stopped = true;
|
|
365
|
+
clearInterval(heartbeat);
|
|
366
|
+
clearTimeout(deadline);
|
|
367
|
+
// Let any renewal already in flight settle, so no stray query outlives this
|
|
368
|
+
// call and lands after the caller has closed the client.
|
|
369
|
+
await inFlight;
|
|
370
|
+
};
|
|
371
|
+
let released = false;
|
|
372
|
+
let releasing;
|
|
373
|
+
const control = {
|
|
374
|
+
release: () =>
|
|
375
|
+
(releasing ??= (async () => {
|
|
376
|
+
await stopHeartbeat();
|
|
377
|
+
try {
|
|
378
|
+
await lock.release();
|
|
379
|
+
released = true;
|
|
380
|
+
options.onLockReleased?.({ early: true });
|
|
381
|
+
} catch (releaseError) {
|
|
382
|
+
const message = errorText(releaseError);
|
|
383
|
+
options.logger.warn(
|
|
384
|
+
`⚠ Failed to release the migration lock early: ${message} — it frees itself once ` +
|
|
385
|
+
'its TTL runs out',
|
|
386
|
+
{ event: 'lock:release-failed', error: message, early: true },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return released;
|
|
390
|
+
})()),
|
|
391
|
+
};
|
|
392
|
+
|
|
355
393
|
let result;
|
|
356
394
|
let runError;
|
|
357
395
|
let failed = false;
|
|
358
396
|
try {
|
|
359
|
-
result = await fn(controller.signal);
|
|
397
|
+
result = await fn(controller.signal, control);
|
|
360
398
|
} catch (error) {
|
|
361
399
|
failed = true;
|
|
362
400
|
runError = error;
|
|
363
401
|
} finally {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
clearTimeout(deadline);
|
|
367
|
-
// Let any renewal already in flight settle, so no stray query outlives this
|
|
368
|
-
// call and lands after the caller has closed the client.
|
|
369
|
-
await inFlight;
|
|
402
|
+
await releasing;
|
|
403
|
+
await stopHeartbeat();
|
|
370
404
|
}
|
|
371
405
|
|
|
406
|
+
if (released) {
|
|
407
|
+
if (failed) throw runError;
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
372
410
|
try {
|
|
373
411
|
await lock.release();
|
|
374
412
|
options.onLockReleased?.();
|
package/src/core/migrator.js
CHANGED
|
@@ -408,10 +408,12 @@ class MigratorKit extends EventEmitter {
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
|
-
* Run `fn` under the migration lock. The single place that
|
|
412
|
-
* a unit of work, so `redo` can hold one lock across both
|
|
413
|
-
* of releasing between them. `info` names the run
|
|
414
|
-
* for the `run:start`/`run:end` events.
|
|
411
|
+
* Run `fn(signal, lock)` under the migration lock. The single place that
|
|
412
|
+
* pairs a lock with a unit of work, so `redo` can hold one lock across both
|
|
413
|
+
* directions instead of releasing between them. `info` names the run
|
|
414
|
+
* (`{command, direction?}`) for the `run:start`/`run:end` events.
|
|
415
|
+
* `lock.release()` gives the lock up before `fn` returns, for a tail that
|
|
416
|
+
* only reads (see runWithLock) — the run, its id and its span go on.
|
|
415
417
|
*/
|
|
416
418
|
async #withLock(options, info, fn) {
|
|
417
419
|
// Not reentrant: a second overlapping run on this instance would clobber
|
|
@@ -468,7 +470,7 @@ class MigratorKit extends EventEmitter {
|
|
|
468
470
|
onLockLostEvent: (reason) => recorder.lockLost(reason),
|
|
469
471
|
...(options.noLock ? { noLock: true } : {}),
|
|
470
472
|
},
|
|
471
|
-
(lockSignal) =>
|
|
473
|
+
(lockSignal, lockControl) =>
|
|
472
474
|
// The run's span exists only once the lock is held: a caller polling
|
|
473
475
|
// for a busy lock retries the whole run every few hundred
|
|
474
476
|
// milliseconds, and a span per refusal would bury the one run that
|
|
@@ -476,7 +478,7 @@ class MigratorKit extends EventEmitter {
|
|
|
476
478
|
// recorder after the release, so the span's outcome is the run's.
|
|
477
479
|
this.#telemetry.open(SPANS.RUN, recorder.spanAttributes(), (span) => {
|
|
478
480
|
recorder.spanOpened(span);
|
|
479
|
-
return fn(AbortSignal.any([lockSignal, stopper.signal]));
|
|
481
|
+
return fn(AbortSignal.any([lockSignal, stopper.signal]), lockControl);
|
|
480
482
|
}),
|
|
481
483
|
);
|
|
482
484
|
return result;
|
|
@@ -960,7 +962,7 @@ class MigratorKit extends EventEmitter {
|
|
|
960
962
|
: [];
|
|
961
963
|
return this.#keepDefinitionsWhileRefused(async () => {
|
|
962
964
|
await this.connect();
|
|
963
|
-
return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal) => {
|
|
965
|
+
return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal, lock) => {
|
|
964
966
|
const results = await this.#runUp(filename, options, signal);
|
|
965
967
|
// Even when nothing was pending: a converge that failed last time is
|
|
966
968
|
// retried by the next `up` instead of waiting for the next migration.
|
|
@@ -968,8 +970,13 @@ class MigratorKit extends EventEmitter {
|
|
|
968
970
|
this.#assertNotAborted(signal, results);
|
|
969
971
|
try {
|
|
970
972
|
await runConverge(
|
|
971
|
-
this.#convergeDeps(),
|
|
972
|
-
{
|
|
973
|
+
this.#convergeDeps(lock),
|
|
974
|
+
{
|
|
975
|
+
definitions,
|
|
976
|
+
trigger: 'up',
|
|
977
|
+
search: this.#convergeSearchOptions(),
|
|
978
|
+
...pickActor(options),
|
|
979
|
+
},
|
|
973
980
|
signal,
|
|
974
981
|
);
|
|
975
982
|
} catch (error) {
|
|
@@ -1550,6 +1557,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1550
1557
|
getDb: () => this.#requireDb(),
|
|
1551
1558
|
inspectLock: () => this.#buildLock().inspect(),
|
|
1552
1559
|
status: () => this.status(),
|
|
1560
|
+
definitions: () => this.#resolveCollections(),
|
|
1553
1561
|
});
|
|
1554
1562
|
}
|
|
1555
1563
|
|
|
@@ -1855,7 +1863,13 @@ class MigratorKit extends EventEmitter {
|
|
|
1855
1863
|
await this.connect();
|
|
1856
1864
|
return runConverge(
|
|
1857
1865
|
this.#convergeDeps(),
|
|
1858
|
-
{
|
|
1866
|
+
{
|
|
1867
|
+
definitions,
|
|
1868
|
+
prune: options.prune,
|
|
1869
|
+
rebuildUnique: options.rebuildUnique,
|
|
1870
|
+
dryRun: true,
|
|
1871
|
+
search: this.#convergeSearchOptions(),
|
|
1872
|
+
},
|
|
1859
1873
|
undefined,
|
|
1860
1874
|
);
|
|
1861
1875
|
}
|
|
@@ -1872,11 +1886,17 @@ class MigratorKit extends EventEmitter {
|
|
|
1872
1886
|
return empty(false);
|
|
1873
1887
|
}
|
|
1874
1888
|
await this.connect();
|
|
1875
|
-
return this.#withLock(options, { command: 'converge' }, async (signal) => {
|
|
1889
|
+
return this.#withLock(options, { command: 'converge' }, async (signal, lock) => {
|
|
1876
1890
|
if (options.ordered) await this.#assertNothingPending();
|
|
1877
1891
|
return runConverge(
|
|
1878
|
-
this.#convergeDeps(),
|
|
1879
|
-
{
|
|
1892
|
+
this.#convergeDeps(lock),
|
|
1893
|
+
{
|
|
1894
|
+
definitions,
|
|
1895
|
+
prune: options.prune,
|
|
1896
|
+
rebuildUnique: options.rebuildUnique,
|
|
1897
|
+
search: this.#convergeSearchOptions(options),
|
|
1898
|
+
...actor,
|
|
1899
|
+
},
|
|
1880
1900
|
signal,
|
|
1881
1901
|
);
|
|
1882
1902
|
});
|
|
@@ -1912,10 +1932,23 @@ class MigratorKit extends EventEmitter {
|
|
|
1912
1932
|
});
|
|
1913
1933
|
}
|
|
1914
1934
|
|
|
1915
|
-
|
|
1935
|
+
/** How converge treats search indexes: the config, and a call's own `waitForSearchIndexes` */
|
|
1936
|
+
#convergeSearchOptions(options = {}) {
|
|
1937
|
+
const config = this.#config;
|
|
1938
|
+
return {
|
|
1939
|
+
onUnavailable: config.onSearchUnavailable,
|
|
1940
|
+
wait: options.waitForSearchIndexes ?? config.waitForSearchIndexes,
|
|
1941
|
+
waitTimeoutMs: config.searchIndexWaitTimeoutMs,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** What runConverge works with; `lock` (a run's) lets it give the lock up before waiting */
|
|
1946
|
+
#convergeDeps(lock) {
|
|
1916
1947
|
const db = this.#requireDb();
|
|
1917
1948
|
return {
|
|
1918
1949
|
db,
|
|
1950
|
+
...(lock ? { releaseLock: () => lock.release() } : {}),
|
|
1951
|
+
recordSearchWait: (waitedMs, outcome) => this.#telemetry.searchWaited({ waitedMs, outcome }),
|
|
1919
1952
|
logger: this.#logger,
|
|
1920
1953
|
fields: (extra) => this.#fields(extra),
|
|
1921
1954
|
emit: (event, payload) => this.#emit(event, payload),
|
package/src/core/options.js
CHANGED
|
@@ -198,11 +198,26 @@ function assertDryRunOptions(filename, options) {
|
|
|
198
198
|
|
|
199
199
|
/** `converge(options)` */
|
|
200
200
|
function assertConvergeOptions(options) {
|
|
201
|
-
for (const key of [
|
|
201
|
+
for (const key of [
|
|
202
|
+
'dryRun',
|
|
203
|
+
'prune',
|
|
204
|
+
'noLock',
|
|
205
|
+
'ordered',
|
|
206
|
+
'rebuildUnique',
|
|
207
|
+
'waitForSearchIndexes',
|
|
208
|
+
]) {
|
|
202
209
|
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
203
210
|
throw new ConfigInvalidError(`${key} must be a boolean`, { [key]: options[key] });
|
|
204
211
|
}
|
|
205
212
|
}
|
|
213
|
+
// A dry run builds nothing, so there is nothing to wait for — asking for
|
|
214
|
+
// both is a mistake worth saying, not a wait that silently never happens.
|
|
215
|
+
if (options.dryRun && options.waitForSearchIndexes) {
|
|
216
|
+
throw new ConfigInvalidError('waitForSearchIndexes cannot be combined with dryRun', {
|
|
217
|
+
dryRun: true,
|
|
218
|
+
waitForSearchIndexes: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
206
221
|
assertActorValid(options);
|
|
207
222
|
}
|
|
208
223
|
|