@alexify/migronaut 2.1.0 → 2.3.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 +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +605 -0
- package/src/core/background.js +1121 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- package/src/utils/error.js +11 -2
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +125 -1
- package/src/utils/template.js +69 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/src/core/migrator.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('node:path');
|
|
|
6
6
|
// and pulls in ~150 modules, which `--help`, `--version`, `init` and `create`
|
|
7
7
|
// have no use for.
|
|
8
8
|
const {
|
|
9
|
+
BackgroundPendingError,
|
|
9
10
|
ChecksumMismatchError,
|
|
10
11
|
ConfigInvalidError,
|
|
11
12
|
ConnectionFailedError,
|
|
@@ -13,6 +14,7 @@ const {
|
|
|
13
14
|
IrreversibleMigrationError,
|
|
14
15
|
LockAlreadyHeldError,
|
|
15
16
|
MigrationFileNotFoundError,
|
|
17
|
+
MigrationInvalidExportError,
|
|
16
18
|
MigrationInvalidNameError,
|
|
17
19
|
MigronautError,
|
|
18
20
|
NotAppliedError,
|
|
@@ -35,16 +37,48 @@ const {
|
|
|
35
37
|
} = require('../utils/template.js');
|
|
36
38
|
const { safeUsername } = require('../utils/user.js');
|
|
37
39
|
const { runAudit } = require('./audit.js');
|
|
40
|
+
const {
|
|
41
|
+
control: controlBackground,
|
|
42
|
+
coordinate,
|
|
43
|
+
repin: repinBackgroundState,
|
|
44
|
+
runSlice,
|
|
45
|
+
tryUnblock,
|
|
46
|
+
waitForLanes,
|
|
47
|
+
waitingFor,
|
|
48
|
+
} = require('./background.js');
|
|
49
|
+
const { auditFindings } = require('./background-audit.js');
|
|
50
|
+
const {
|
|
51
|
+
STREAMING_FRESH_MS,
|
|
52
|
+
drive,
|
|
53
|
+
hasRewritten,
|
|
54
|
+
irreversibleBackground,
|
|
55
|
+
partitionView,
|
|
56
|
+
runnable,
|
|
57
|
+
stateView,
|
|
58
|
+
unsatisfied,
|
|
59
|
+
watchRow,
|
|
60
|
+
withdraw,
|
|
61
|
+
} = require('./background-kit.js');
|
|
62
|
+
const { verify: verifyDrift } = require('./background-drift.js');
|
|
63
|
+
const { assertSliceMs, resolveBackgroundSpec } = require('./background-spec.js');
|
|
64
|
+
const { previewSample, previewSteps } = require('./background-dry-run.js');
|
|
65
|
+
const { BackgroundStore } = require('./background-store.js');
|
|
66
|
+
const { startWatch, watchOptions } = require('./background-watch.js');
|
|
67
|
+
const { BackgroundWatchStore } = require('./background-watch-store.js');
|
|
38
68
|
const { runBaseline } = require('./baseline.js');
|
|
39
69
|
const { Changelog } = require('./changelog.js');
|
|
40
70
|
const { ConvergeLog } = require('./converge-log.js');
|
|
41
71
|
const { resolveDefinitions } = require('./collections.js');
|
|
42
|
-
const { loadConfig } = require('./config.js');
|
|
72
|
+
const { backgroundCollectionNames, loadConfig } = require('./config.js');
|
|
43
73
|
const { buildContext } = require('./context.js');
|
|
44
74
|
const { runConverge } = require('./converge.js');
|
|
75
|
+
const { readServer } = require('./server-info.js');
|
|
76
|
+
const { readChunks, readShardKey } = require('./shard-info.js');
|
|
77
|
+
|
|
45
78
|
const { runImport } = require('./import-runner.js');
|
|
46
79
|
const { MigrationLock, runWithLock, toLockInfo } = require('./lock.js');
|
|
47
80
|
const {
|
|
81
|
+
assertActorValid,
|
|
48
82
|
assertConvergeOptions,
|
|
49
83
|
assertDownOptions,
|
|
50
84
|
assertDryRunOptions,
|
|
@@ -140,6 +174,21 @@ class MigratorKit extends EventEmitter {
|
|
|
140
174
|
* poll that the module cache never frees. Cleared by any other outcome.
|
|
141
175
|
*/
|
|
142
176
|
#upDefinitions;
|
|
177
|
+
/** The background migrations' store — made on first use (see #backgroundStore) */
|
|
178
|
+
#backgroundStoreInstance;
|
|
179
|
+
#watchStoreInstance;
|
|
180
|
+
/** Warnings about background migrations said once per kit (a missing index, no lag rights) */
|
|
181
|
+
#backgroundWarned = new Set();
|
|
182
|
+
/** The adaptive throttles of this process, per background migration and group */
|
|
183
|
+
#adaptiveCache = new Map();
|
|
184
|
+
/** Index keys per collection, read at most every few seconds — every slice asks */
|
|
185
|
+
#indexCache = new Map();
|
|
186
|
+
/** Declared collections, for background specs — resolved once unless migrations reload */
|
|
187
|
+
#backgroundDefinitions;
|
|
188
|
+
/** The server's topology, read once — transactional background migrations need it */
|
|
189
|
+
#topology;
|
|
190
|
+
/** Migration modules read ahead of a run (requires, kind), by checksum */
|
|
191
|
+
#moduleCache = new Map();
|
|
143
192
|
|
|
144
193
|
constructor(config = {}, options = {}) {
|
|
145
194
|
super();
|
|
@@ -369,6 +418,12 @@ class MigratorKit extends EventEmitter {
|
|
|
369
418
|
this.#client = undefined;
|
|
370
419
|
this.#db = undefined;
|
|
371
420
|
this.#changelog = undefined;
|
|
421
|
+
// The background stores bind the Db they were made with, and the topology
|
|
422
|
+
// and definitions were read through it: a later connect() reads them anew.
|
|
423
|
+
this.#backgroundStoreInstance = undefined;
|
|
424
|
+
this.#watchStoreInstance = undefined;
|
|
425
|
+
this.#topology = undefined;
|
|
426
|
+
this.#backgroundDefinitions = undefined;
|
|
372
427
|
this.#ownsClient = true;
|
|
373
428
|
if (owned) await client.close();
|
|
374
429
|
}
|
|
@@ -408,10 +463,12 @@ class MigratorKit extends EventEmitter {
|
|
|
408
463
|
}
|
|
409
464
|
|
|
410
465
|
/**
|
|
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.
|
|
466
|
+
* Run `fn(signal, lock)` under the migration lock. The single place that
|
|
467
|
+
* pairs a lock with a unit of work, so `redo` can hold one lock across both
|
|
468
|
+
* directions instead of releasing between them. `info` names the run
|
|
469
|
+
* (`{command, direction?}`) for the `run:start`/`run:end` events.
|
|
470
|
+
* `lock.release()` gives the lock up before `fn` returns, for a tail that
|
|
471
|
+
* only reads (see runWithLock) — the run, its id and its span go on.
|
|
415
472
|
*/
|
|
416
473
|
async #withLock(options, info, fn) {
|
|
417
474
|
// Not reentrant: a second overlapping run on this instance would clobber
|
|
@@ -468,7 +525,7 @@ class MigratorKit extends EventEmitter {
|
|
|
468
525
|
onLockLostEvent: (reason) => recorder.lockLost(reason),
|
|
469
526
|
...(options.noLock ? { noLock: true } : {}),
|
|
470
527
|
},
|
|
471
|
-
(lockSignal) =>
|
|
528
|
+
(lockSignal, lockControl) =>
|
|
472
529
|
// The run's span exists only once the lock is held: a caller polling
|
|
473
530
|
// for a busy lock retries the whole run every few hundred
|
|
474
531
|
// milliseconds, and a span per refusal would bury the one run that
|
|
@@ -476,7 +533,7 @@ class MigratorKit extends EventEmitter {
|
|
|
476
533
|
// recorder after the release, so the span's outcome is the run's.
|
|
477
534
|
this.#telemetry.open(SPANS.RUN, recorder.spanAttributes(), (span) => {
|
|
478
535
|
recorder.spanOpened(span);
|
|
479
|
-
return fn(AbortSignal.any([lockSignal, stopper.signal]));
|
|
536
|
+
return fn(AbortSignal.any([lockSignal, stopper.signal]), lockControl);
|
|
480
537
|
}),
|
|
481
538
|
);
|
|
482
539
|
return result;
|
|
@@ -732,6 +789,8 @@ class MigratorKit extends EventEmitter {
|
|
|
732
789
|
this.#assertNotAborted(signal, results);
|
|
733
790
|
const outcome = await execute(name, index, results);
|
|
734
791
|
if (outcome === 'done') doneCount += 1;
|
|
792
|
+
// A clean stop: what follows waits for a background migration.
|
|
793
|
+
if (outcome === 'stop') break;
|
|
735
794
|
}
|
|
736
795
|
} catch (error) {
|
|
737
796
|
failure = error;
|
|
@@ -823,9 +882,15 @@ class MigratorKit extends EventEmitter {
|
|
|
823
882
|
context,
|
|
824
883
|
{ direction, index, total },
|
|
825
884
|
]);
|
|
826
|
-
const
|
|
885
|
+
const loaded = await loadMigrationFile(this.#filepath(name), {
|
|
827
886
|
reload: config.reloadMigrations,
|
|
828
887
|
});
|
|
888
|
+
let migration = loaded;
|
|
889
|
+
if (loaded.kind === 'background') {
|
|
890
|
+
// Indexes cannot be created inside a transaction — the registration may run in one.
|
|
891
|
+
await this.#backgroundIndexes();
|
|
892
|
+
migration = await this.#asBackground(name, loaded, direction);
|
|
893
|
+
}
|
|
829
894
|
const useTransaction = migration.useTransaction ?? config.useTransaction;
|
|
830
895
|
span.set({ [ATTRIBUTES.MIGRATION_TRANSACTION]: useTransaction });
|
|
831
896
|
|
|
@@ -852,11 +917,21 @@ class MigratorKit extends EventEmitter {
|
|
|
852
917
|
...batchField,
|
|
853
918
|
durationMs: duration,
|
|
854
919
|
});
|
|
855
|
-
const label =
|
|
920
|
+
const label =
|
|
921
|
+
migration.kind === 'background'
|
|
922
|
+
? direction === 'up'
|
|
923
|
+
? '⧗ Registered'
|
|
924
|
+
: '⧗ Reverting'
|
|
925
|
+
: direction === 'up'
|
|
926
|
+
? '✔ Applied '
|
|
927
|
+
: '↩ Reverted';
|
|
856
928
|
logger.info(
|
|
857
929
|
`${label} ${name} [${duration}ms]`,
|
|
858
930
|
this.#fields({ migration: name, direction, ...batchField, durationMs: duration }),
|
|
859
931
|
);
|
|
932
|
+
if (migration.registered) {
|
|
933
|
+
this.#emit('background:registered', { migration: name, ...migration.registered });
|
|
934
|
+
}
|
|
860
935
|
results.push({
|
|
861
936
|
file: name,
|
|
862
937
|
status: direction === 'up' ? 'applied' : 'reverted',
|
|
@@ -960,7 +1035,7 @@ class MigratorKit extends EventEmitter {
|
|
|
960
1035
|
: [];
|
|
961
1036
|
return this.#keepDefinitionsWhileRefused(async () => {
|
|
962
1037
|
await this.connect();
|
|
963
|
-
return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal) => {
|
|
1038
|
+
return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal, lock) => {
|
|
964
1039
|
const results = await this.#runUp(filename, options, signal);
|
|
965
1040
|
// Even when nothing was pending: a converge that failed last time is
|
|
966
1041
|
// retried by the next `up` instead of waiting for the next migration.
|
|
@@ -968,8 +1043,13 @@ class MigratorKit extends EventEmitter {
|
|
|
968
1043
|
this.#assertNotAborted(signal, results);
|
|
969
1044
|
try {
|
|
970
1045
|
await runConverge(
|
|
971
|
-
this.#convergeDeps(),
|
|
972
|
-
{
|
|
1046
|
+
this.#convergeDeps(lock),
|
|
1047
|
+
{
|
|
1048
|
+
definitions,
|
|
1049
|
+
trigger: 'up',
|
|
1050
|
+
search: this.#convergeSearchOptions(),
|
|
1051
|
+
...pickActor(options),
|
|
1052
|
+
},
|
|
973
1053
|
signal,
|
|
974
1054
|
);
|
|
975
1055
|
} catch (error) {
|
|
@@ -1132,6 +1212,25 @@ class MigratorKit extends EventEmitter {
|
|
|
1132
1212
|
);
|
|
1133
1213
|
}
|
|
1134
1214
|
|
|
1215
|
+
// Before beforeEach: a migration that cannot run yet fires no hook and
|
|
1216
|
+
// leaves no failed trace.
|
|
1217
|
+
const waiting = await this.#requiresGuard(name, signal);
|
|
1218
|
+
if (waiting.length > 0) {
|
|
1219
|
+
const list = waiting.map((entry) => `${entry.migration} (${entry.status})`).join(', ');
|
|
1220
|
+
if ((options.onBackgroundPending ?? 'error') === 'stop') {
|
|
1221
|
+
logger.info(
|
|
1222
|
+
`⧗ Waiting ${name} — requires ${list}`,
|
|
1223
|
+
this.#fields({ migration: name, direction: 'up', waitsFor: waiting.length }),
|
|
1224
|
+
);
|
|
1225
|
+
this.#emit('background:waiting', { migration: name, waitsFor: waiting });
|
|
1226
|
+
return 'stop';
|
|
1227
|
+
}
|
|
1228
|
+
throw new BackgroundPendingError(
|
|
1229
|
+
`${name} requires background migration(s) that have not completed: ${list}`,
|
|
1230
|
+
{ migration: name, waitsFor: waiting },
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1135
1234
|
const batch = options.step ? baseBatch + appliedCount : baseBatch;
|
|
1136
1235
|
await this.#executeMigration({
|
|
1137
1236
|
name,
|
|
@@ -1156,6 +1255,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1156
1255
|
duration: elapsed,
|
|
1157
1256
|
...(this.#runId ? { runId: this.#runId } : {}),
|
|
1158
1257
|
...(migration.description ? { description: migration.description } : {}),
|
|
1258
|
+
...(migration.kind === 'background' ? { kind: 'background' } : {}),
|
|
1159
1259
|
...actorFields(options),
|
|
1160
1260
|
},
|
|
1161
1261
|
session,
|
|
@@ -1166,6 +1266,9 @@ class MigratorKit extends EventEmitter {
|
|
|
1166
1266
|
failureFields: { checksum, ...actorFields(options) },
|
|
1167
1267
|
});
|
|
1168
1268
|
appliedCount += 1;
|
|
1269
|
+
if (this.#config.backgroundInline && (await this.#peek(name))?.kind === 'background') {
|
|
1270
|
+
await this.#runInline(name, signal);
|
|
1271
|
+
}
|
|
1169
1272
|
return 'done';
|
|
1170
1273
|
},
|
|
1171
1274
|
});
|
|
@@ -1473,6 +1576,8 @@ class MigratorKit extends EventEmitter {
|
|
|
1473
1576
|
const { records, preserveOrder } = await this.#selectDownTargets(filename, options);
|
|
1474
1577
|
for (const record of records) {
|
|
1475
1578
|
recordByName.set(record.name, record);
|
|
1579
|
+
// The refusal a real `down` of a one-way background migration meets.
|
|
1580
|
+
if (record.kind === 'background') await this.#assertBackgroundRevertible(record.name);
|
|
1476
1581
|
}
|
|
1477
1582
|
names = revertOrder(records, preserveOrder);
|
|
1478
1583
|
}
|
|
@@ -1483,6 +1588,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1483
1588
|
// later (a queue job) can insist on exactly this version of the file.
|
|
1484
1589
|
if (direction === 'up' && !row.invalid) {
|
|
1485
1590
|
row.checksum = await this.#cachedChecksum(this.#filepath(name));
|
|
1591
|
+
await this.#annotateBackground(row, name);
|
|
1486
1592
|
}
|
|
1487
1593
|
return row;
|
|
1488
1594
|
});
|
|
@@ -1507,6 +1613,28 @@ class MigratorKit extends EventEmitter {
|
|
|
1507
1613
|
return rows;
|
|
1508
1614
|
}
|
|
1509
1615
|
|
|
1616
|
+
/**
|
|
1617
|
+
* A preview row's background facts: a background file, what it requires,
|
|
1618
|
+
* and what of that is not done yet — read without writing anything. A file
|
|
1619
|
+
* that does not load is left for the real run to report.
|
|
1620
|
+
*/
|
|
1621
|
+
async #annotateBackground(row, name) {
|
|
1622
|
+
let loaded;
|
|
1623
|
+
try {
|
|
1624
|
+
loaded = await this.#peek(name);
|
|
1625
|
+
} catch {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (loaded === null) return;
|
|
1629
|
+
// One fact, one field: `kind`, as a status row says it.
|
|
1630
|
+
if (loaded.kind === 'background') row.kind = 'background';
|
|
1631
|
+
const requires = loaded.requires ?? [];
|
|
1632
|
+
if (requires.length === 0) return;
|
|
1633
|
+
row.requires = requires;
|
|
1634
|
+
const waiting = await this.#unsatisfied(requires, { reopen: false }).catch(() => []);
|
|
1635
|
+
if (waiting.length > 0) row.waitsFor = waiting.map((entry) => entry.migration);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1510
1638
|
/** Full migration status for all known files and records */
|
|
1511
1639
|
async status(options = {}) {
|
|
1512
1640
|
await this.#ensureConfig();
|
|
@@ -1550,6 +1678,23 @@ class MigratorKit extends EventEmitter {
|
|
|
1550
1678
|
getDb: () => this.#requireDb(),
|
|
1551
1679
|
inspectLock: () => this.#buildLock().inspect(),
|
|
1552
1680
|
status: () => this.status(),
|
|
1681
|
+
definitions: () => this.#resolveCollections(),
|
|
1682
|
+
background: async () => {
|
|
1683
|
+
const deps = this.#backgroundDeps();
|
|
1684
|
+
return auditFindings({
|
|
1685
|
+
...deps,
|
|
1686
|
+
checksumOf: (name) => computeChecksum(this.#filepath(name)),
|
|
1687
|
+
backgroundRecords: () =>
|
|
1688
|
+
this.#requireDb()
|
|
1689
|
+
.collection(this.#config.migrationsCollection)
|
|
1690
|
+
.find({ kind: 'background', status: 'applied' }, { projection: { name: 1 } })
|
|
1691
|
+
.toArray(),
|
|
1692
|
+
// Only where drift is streamed: a watcher's record is not a finding otherwise.
|
|
1693
|
+
...(this.#config.backgroundDrift !== 'poll'
|
|
1694
|
+
? { watchRows: () => this.#watchStore().status() }
|
|
1695
|
+
: {}),
|
|
1696
|
+
});
|
|
1697
|
+
},
|
|
1553
1698
|
});
|
|
1554
1699
|
}
|
|
1555
1700
|
|
|
@@ -1699,6 +1844,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1699
1844
|
duration: isApplied && record ? record.duration : null,
|
|
1700
1845
|
checksumOk,
|
|
1701
1846
|
...(record?.description ? { description: record.description } : {}),
|
|
1847
|
+
...(record?.kind === 'background' ? { kind: 'background' } : {}),
|
|
1702
1848
|
...this.#auditFields(record),
|
|
1703
1849
|
};
|
|
1704
1850
|
}
|
|
@@ -1711,6 +1857,12 @@ class MigratorKit extends EventEmitter {
|
|
|
1711
1857
|
* fail (or reach the network at all) when that manager is unreachable.
|
|
1712
1858
|
*/
|
|
1713
1859
|
async create(name, options = {}) {
|
|
1860
|
+
if (options.background && options.template !== undefined) {
|
|
1861
|
+
throw new ConfigInvalidError(
|
|
1862
|
+
'create: background and template do not combine — a background migration is ' +
|
|
1863
|
+
'scaffolded from its own template',
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1714
1866
|
const config = await this.#ensureConfig(false, true);
|
|
1715
1867
|
const dir = this.#migrationsPath();
|
|
1716
1868
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -1723,6 +1875,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1723
1875
|
js,
|
|
1724
1876
|
fileExtensions: config.fileExtensions,
|
|
1725
1877
|
...(templatePath ? { templatePath } : {}),
|
|
1878
|
+
...(options.background ? { background: true } : {}),
|
|
1726
1879
|
});
|
|
1727
1880
|
this.#logger.info(
|
|
1728
1881
|
`✔ Created ${path.basename(filepath)}`,
|
|
@@ -1855,7 +2008,13 @@ class MigratorKit extends EventEmitter {
|
|
|
1855
2008
|
await this.connect();
|
|
1856
2009
|
return runConverge(
|
|
1857
2010
|
this.#convergeDeps(),
|
|
1858
|
-
{
|
|
2011
|
+
{
|
|
2012
|
+
definitions,
|
|
2013
|
+
prune: options.prune,
|
|
2014
|
+
rebuildUnique: options.rebuildUnique,
|
|
2015
|
+
dryRun: true,
|
|
2016
|
+
search: this.#convergeSearchOptions(),
|
|
2017
|
+
},
|
|
1859
2018
|
undefined,
|
|
1860
2019
|
);
|
|
1861
2020
|
}
|
|
@@ -1872,11 +2031,17 @@ class MigratorKit extends EventEmitter {
|
|
|
1872
2031
|
return empty(false);
|
|
1873
2032
|
}
|
|
1874
2033
|
await this.connect();
|
|
1875
|
-
return this.#withLock(options, { command: 'converge' }, async (signal) => {
|
|
2034
|
+
return this.#withLock(options, { command: 'converge' }, async (signal, lock) => {
|
|
1876
2035
|
if (options.ordered) await this.#assertNothingPending();
|
|
1877
2036
|
return runConverge(
|
|
1878
|
-
this.#convergeDeps(),
|
|
1879
|
-
{
|
|
2037
|
+
this.#convergeDeps(lock),
|
|
2038
|
+
{
|
|
2039
|
+
definitions,
|
|
2040
|
+
prune: options.prune,
|
|
2041
|
+
rebuildUnique: options.rebuildUnique,
|
|
2042
|
+
search: this.#convergeSearchOptions(options),
|
|
2043
|
+
...actor,
|
|
2044
|
+
},
|
|
1880
2045
|
signal,
|
|
1881
2046
|
);
|
|
1882
2047
|
});
|
|
@@ -1908,14 +2073,775 @@ class MigratorKit extends EventEmitter {
|
|
|
1908
2073
|
: {}),
|
|
1909
2074
|
extensions: config.fileExtensions,
|
|
1910
2075
|
reload: config.reloadMigrations,
|
|
1911
|
-
reserved:
|
|
2076
|
+
reserved: this.#bookkeepingNames(),
|
|
1912
2077
|
});
|
|
1913
2078
|
}
|
|
1914
2079
|
|
|
1915
|
-
|
|
2080
|
+
/** Every collection migronaut keeps its own records in — never a declared one */
|
|
2081
|
+
#bookkeepingNames() {
|
|
2082
|
+
const config = this.#config;
|
|
2083
|
+
const names = [
|
|
2084
|
+
config.migrationsCollection,
|
|
2085
|
+
config.lockCollection,
|
|
2086
|
+
config.convergeLogCollection,
|
|
2087
|
+
];
|
|
2088
|
+
if (config.backgroundCollection !== undefined) {
|
|
2089
|
+
const background = backgroundCollectionNames(config.backgroundCollection);
|
|
2090
|
+
names.push(background.state, background.partitions, background.watch);
|
|
2091
|
+
}
|
|
2092
|
+
return names;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// ─── Background migrations ──────────────────────────────────────────────────
|
|
2096
|
+
|
|
2097
|
+
/** The store of background migration state, over this kit's database */
|
|
2098
|
+
#backgroundStore() {
|
|
2099
|
+
this.#backgroundStoreInstance ??= new BackgroundStore(
|
|
2100
|
+
this.#requireDb(),
|
|
2101
|
+
this.#config.backgroundCollection,
|
|
2102
|
+
);
|
|
2103
|
+
return this.#backgroundStoreInstance;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* A background migration file as the run loop sees a migration: its `up`
|
|
2108
|
+
* registers the background migration (the documents are rewritten later,
|
|
2109
|
+
* in partitions, without the migration lock); its `down` sets off the way
|
|
2110
|
+
* back — or, when it has none, withdraws it while nothing was rewritten yet.
|
|
2111
|
+
* Everything around them is the regular path: hooks, span, transaction,
|
|
2112
|
+
* changelog (with `kind: 'background'`).
|
|
2113
|
+
*/
|
|
2114
|
+
async #asBackground(name, loaded, direction) {
|
|
2115
|
+
// Validated before it runs: an invalid file is reported as itself, not as
|
|
2116
|
+
// a failed execution — and nothing is written. A down goes by the spec it
|
|
2117
|
+
// was registered with: a definition file broken since must not block it.
|
|
2118
|
+
const { spec } =
|
|
2119
|
+
direction === 'down'
|
|
2120
|
+
? await this.#registeredSpec(name, loaded)
|
|
2121
|
+
: await this.#backgroundSpec(name, loaded);
|
|
2122
|
+
if (direction === 'up') await this.#assertRequiresAreBackground(name, loaded.requires ?? []);
|
|
2123
|
+
const migration = {
|
|
2124
|
+
kind: 'background',
|
|
2125
|
+
...(loaded.description !== undefined ? { description: loaded.description } : {}),
|
|
2126
|
+
up: async (ctx) => {
|
|
2127
|
+
migration.registered = await this.#registerBackground(name, loaded, spec, ctx.session);
|
|
2128
|
+
},
|
|
2129
|
+
down: async (ctx) => {
|
|
2130
|
+
migration.registered = await this.#revertBackground(name, spec, ctx.session);
|
|
2131
|
+
},
|
|
2132
|
+
};
|
|
2133
|
+
return migration;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/**
|
|
2137
|
+
* The spec a registered background migration runs by — the one stored at
|
|
2138
|
+
* its registration — with the file's functions. Not registered: the file's.
|
|
2139
|
+
*/
|
|
2140
|
+
async #registeredSpec(name, loaded) {
|
|
2141
|
+
const state = await this.#backgroundStore().get(name);
|
|
2142
|
+
if (state?.spec === undefined) return this.#backgroundSpec(name, loaded);
|
|
2143
|
+
const { fns } = await this.#backgroundSpec(name, loaded, { tolerant: true });
|
|
2144
|
+
return { spec: state.spec, fns };
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* The spec of a background migration file, resolved against the
|
|
2149
|
+
* collection's declared versioning (when it has one). `tolerant`: for a
|
|
2150
|
+
* registered one, whose stored spec stands — a definition file that does
|
|
2151
|
+
* not load is warned about and passed over (only its functions are used).
|
|
2152
|
+
*/
|
|
2153
|
+
async #backgroundSpec(name, loaded, { tolerant = false } = {}) {
|
|
2154
|
+
const raw = loaded.background;
|
|
2155
|
+
let versioning;
|
|
2156
|
+
const collection = raw?.collection;
|
|
2157
|
+
if (typeof collection === 'string') {
|
|
2158
|
+
if (this.#bookkeepingNames().includes(collection)) {
|
|
2159
|
+
throw new MigrationInvalidExportError(
|
|
2160
|
+
`Background migration ${name} targets "${collection}", one of migronaut's own collections`,
|
|
2161
|
+
{ name, collection },
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
const config = this.#config;
|
|
2165
|
+
if (config.collections !== undefined || config.collectionsDir !== undefined) {
|
|
2166
|
+
try {
|
|
2167
|
+
versioning = await this.#versioningOf(collection, { strict: true });
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
if (!tolerant) throw error;
|
|
2170
|
+
this.#warnOnce(
|
|
2171
|
+
`definitions:${collection}`,
|
|
2172
|
+
`⚠ The collection definitions do not load (${errorText(error)}) — ${name} goes by ` +
|
|
2173
|
+
'the spec it was registered with',
|
|
2174
|
+
{ background: name },
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return resolveBackgroundSpec(raw, { name, versioning });
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* What a file's `requires` names must be: background migration files of
|
|
2184
|
+
* the sequence. The order is the loader's to check (each sorts before the
|
|
2185
|
+
* file); whether they are done is the caller's.
|
|
2186
|
+
*/
|
|
2187
|
+
async #assertRequiresAreBackground(name, requires) {
|
|
2188
|
+
if (requires.length === 0) return;
|
|
2189
|
+
const sequence = new Set(await this.#listMigrationFiles());
|
|
2190
|
+
for (const required of requires) {
|
|
2191
|
+
if (!sequence.has(required)) {
|
|
2192
|
+
throw new MigrationInvalidExportError(
|
|
2193
|
+
`${name} requires ${required}, which is not a migration of the sequence`,
|
|
2194
|
+
{ name, requires: required },
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
// Through the module cache: this runs for every file that requires one.
|
|
2198
|
+
const loaded =
|
|
2199
|
+
(await this.#peek(required)) ??
|
|
2200
|
+
(await loadMigrationFile(this.#filepath(required), {
|
|
2201
|
+
reload: this.#config.reloadMigrations,
|
|
2202
|
+
}));
|
|
2203
|
+
if (loaded.kind !== 'background') {
|
|
2204
|
+
throw new MigrationInvalidExportError(
|
|
2205
|
+
`${name} requires ${required}, which is not a background migration — requires ` +
|
|
2206
|
+
'waits for background migrations only (regular ones already run in order)',
|
|
2207
|
+
{ name, requires: required },
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** The required background migrations not done yet, in order */
|
|
2214
|
+
#waitsFor(requires) {
|
|
2215
|
+
return waitingFor(this.#backgroundDeps(), requires);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/** Register a background migration (again): `blocked` while what it requires is not done */
|
|
2219
|
+
async #registerBackground(name, loaded, spec, session) {
|
|
2220
|
+
const requires = loaded.requires ?? [];
|
|
2221
|
+
const waitsFor = await this.#waitsFor(requires);
|
|
2222
|
+
const status = waitsFor.length > 0 ? 'blocked' : 'pending';
|
|
2223
|
+
await this.#backgroundStore().register(
|
|
2224
|
+
name,
|
|
2225
|
+
{
|
|
2226
|
+
status,
|
|
2227
|
+
mode: spec.mode,
|
|
2228
|
+
direction: 'forward',
|
|
2229
|
+
spec,
|
|
2230
|
+
checksum: await computeChecksum(this.#filepath(name)),
|
|
2231
|
+
requires,
|
|
2232
|
+
waitsFor,
|
|
2233
|
+
...(spec.collection !== undefined ? { collection: spec.collection } : {}),
|
|
2234
|
+
...(loaded.description !== undefined ? { description: loaded.description } : {}),
|
|
2235
|
+
},
|
|
2236
|
+
{ session },
|
|
2237
|
+
);
|
|
2238
|
+
return { status, direction: 'forward', ...(waitsFor.length > 0 ? { waitsFor } : {}) };
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/** What a `down` of a background migration would refuse — for a preview to refuse it too */
|
|
2242
|
+
async #assertBackgroundRevertible(name) {
|
|
2243
|
+
const loaded = await this.#peek(name);
|
|
2244
|
+
if (loaded === null || loaded.kind !== 'background') return;
|
|
2245
|
+
const { spec } = await this.#registeredSpec(name, loaded);
|
|
2246
|
+
if (spec.reversible) return;
|
|
2247
|
+
if (await hasRewritten(this.#backgroundStore(), name, spec)) {
|
|
2248
|
+
throw irreversibleBackground(name);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* `down` of a background migration: with a `revert`, the forward one is
|
|
2254
|
+
* replaced by the way back (registered to run like any other); without one,
|
|
2255
|
+
* it is withdrawn while nothing has been rewritten (background-kit.js).
|
|
2256
|
+
*/
|
|
2257
|
+
async #revertBackground(name, spec, session) {
|
|
2258
|
+
const store = this.#backgroundStore();
|
|
2259
|
+
if (spec.reversible) {
|
|
2260
|
+
await store.register(
|
|
2261
|
+
name,
|
|
2262
|
+
{
|
|
2263
|
+
status: 'pending',
|
|
2264
|
+
mode: spec.mode,
|
|
2265
|
+
direction: 'revert',
|
|
2266
|
+
spec,
|
|
2267
|
+
checksum: await computeChecksum(this.#filepath(name)),
|
|
2268
|
+
requires: [],
|
|
2269
|
+
waitsFor: [],
|
|
2270
|
+
...(spec.collection !== undefined ? { collection: spec.collection } : {}),
|
|
2271
|
+
},
|
|
2272
|
+
{ session },
|
|
2273
|
+
);
|
|
2274
|
+
return { status: 'pending', direction: 'revert' };
|
|
2275
|
+
}
|
|
2276
|
+
return withdraw(this.#backgroundHost(), name, spec, session);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** What background-kit.js runs with */
|
|
2280
|
+
#backgroundHost() {
|
|
2281
|
+
return {
|
|
2282
|
+
store: this.#backgroundStore(),
|
|
2283
|
+
deps: (owner) => this.#backgroundDeps(owner),
|
|
2284
|
+
newId: () => this.#newId(),
|
|
2285
|
+
logger: this.#logger,
|
|
2286
|
+
emit: (event, payload) => this.#emit(event, payload),
|
|
2287
|
+
registered: (name) => this.#registered(name),
|
|
2288
|
+
status: (name) => this.backgroundStatus(name),
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/** What background.js runs with — `owner` names a lane: its lease's owner, its events' runId */
|
|
2293
|
+
#backgroundDeps(owner) {
|
|
2294
|
+
const config = this.#config;
|
|
1916
2295
|
const db = this.#requireDb();
|
|
2296
|
+
const stamp = owner ? { runId: owner } : {};
|
|
1917
2297
|
return {
|
|
1918
2298
|
db,
|
|
2299
|
+
client: this.#client,
|
|
2300
|
+
store: this.#backgroundStore(),
|
|
2301
|
+
logger: this.#logger,
|
|
2302
|
+
fields: (extra) => ({ ...stamp, ...extra }),
|
|
2303
|
+
emit: (event, payload) => this.#emit(event, { ...stamp, ...payload }),
|
|
2304
|
+
lockFor: (name) =>
|
|
2305
|
+
new MigrationLock(db, config.lockCollection, config.lockTTLSeconds, {
|
|
2306
|
+
id: `background:${name}`,
|
|
2307
|
+
label: 'background coordinator lock',
|
|
2308
|
+
}),
|
|
2309
|
+
load: (name, options) => this.#loadBackground(name, options),
|
|
2310
|
+
ttlMs: config.lockTTLSeconds * 1000,
|
|
2311
|
+
owner: () => owner,
|
|
2312
|
+
warned: this.#backgroundWarned,
|
|
2313
|
+
adaptiveCache: this.#adaptiveCache,
|
|
2314
|
+
indexCache: this.#indexCache,
|
|
2315
|
+
telemetry: this.#telemetry,
|
|
2316
|
+
// The third wrap site: a span per lease held (slice) and per coordinator
|
|
2317
|
+
// step — each opened only once the lease or the lock is held.
|
|
2318
|
+
span: (kind, name, fn) =>
|
|
2319
|
+
this.#telemetry.wrap(
|
|
2320
|
+
kind === 'slice' ? SPANS.BACKGROUND_SLICE : SPANS.BACKGROUND_COORDINATE,
|
|
2321
|
+
{ [ATTRIBUTES.BACKGROUND_NAME]: name },
|
|
2322
|
+
async (span) => {
|
|
2323
|
+
const result = await fn();
|
|
2324
|
+
span.set({ [ATTRIBUTES.BACKGROUND_OUTCOME]: result?.outcome ?? result?.next });
|
|
2325
|
+
return result;
|
|
2326
|
+
},
|
|
2327
|
+
),
|
|
2328
|
+
onCompleted: (name) => this.#unblockDependents(name),
|
|
2329
|
+
adoptedOf: (names) => this.#requireChangelog().getAdoptedNames(db, names),
|
|
2330
|
+
topology: () => this.#serverTopology(db),
|
|
2331
|
+
shardAware: config.backgroundShardAware,
|
|
2332
|
+
shardKeyOf: (collection) => readShardKey(this.#client, db.databaseName, collection),
|
|
2333
|
+
chunksOf: (collection, sharding) =>
|
|
2334
|
+
readChunks(this.#client, db.databaseName, collection, sharding),
|
|
2335
|
+
versioningOf: (collection) => this.#versioningOf(collection),
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* The server's topology, read once it is known. A `hello` that failed (an
|
|
2341
|
+
* election, a blip) is not remembered as "not sharded": it is asked again.
|
|
2342
|
+
*/
|
|
2343
|
+
async #serverTopology(db) {
|
|
2344
|
+
if (this.#topology !== undefined) return this.#topology;
|
|
2345
|
+
const { topology } = await readServer(db);
|
|
2346
|
+
if (topology !== undefined) this.#topology = topology;
|
|
2347
|
+
return topology;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* A declared collection's versioning, or `undefined`. Definitions that do
|
|
2352
|
+
* not load are converge's to report — `undefined` too, unless `strict`.
|
|
2353
|
+
*/
|
|
2354
|
+
async #versioningOf(collection, { strict = false } = {}) {
|
|
2355
|
+
const config = this.#config;
|
|
2356
|
+
if (config.collections === undefined && config.collectionsDir === undefined) return undefined;
|
|
2357
|
+
let definitions;
|
|
2358
|
+
try {
|
|
2359
|
+
definitions = config.reloadMigrations
|
|
2360
|
+
? await this.#resolveCollections()
|
|
2361
|
+
: (this.#backgroundDefinitions ??= await this.#resolveCollections());
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
if (strict) throw error;
|
|
2364
|
+
return undefined;
|
|
2365
|
+
}
|
|
2366
|
+
for (const definition of definitions) {
|
|
2367
|
+
if (definition.name === collection) return definition.versioning;
|
|
2368
|
+
}
|
|
2369
|
+
return undefined;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** Say something about background migrations once per kit */
|
|
2373
|
+
#warnOnce(id, message, fields) {
|
|
2374
|
+
if (this.#backgroundWarned.has(id)) return;
|
|
2375
|
+
this.#backgroundWarned.add(id);
|
|
2376
|
+
this.#logger.warn(message, this.#fields(fields));
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* The live drift watcher: a change stream per collection with a completed
|
|
2381
|
+
* background migration, one leader per collection across every process,
|
|
2382
|
+
* upgrading each old-shape write moments after it lands. Resolves to
|
|
2383
|
+
* `{ running, status(), stop() }` once it has started; on a standalone
|
|
2384
|
+
* server (no change streams) it rejects with ConfigInvalidError.
|
|
2385
|
+
* @experimental
|
|
2386
|
+
*/
|
|
2387
|
+
async watchBackground(options = {}) {
|
|
2388
|
+
// Checked before anything connects: a typo should not wait for a server.
|
|
2389
|
+
watchOptions(options);
|
|
2390
|
+
await this.#backgroundReady();
|
|
2391
|
+
const db = this.#requireDb();
|
|
2392
|
+
if ((await readServer(db)).topology === 'standalone') {
|
|
2393
|
+
throw new ConfigInvalidError(
|
|
2394
|
+
'The live drift watcher needs change streams — a replica set or a sharded cluster; ' +
|
|
2395
|
+
"keep backgroundDrift: 'poll' on a standalone server",
|
|
2396
|
+
{ key: 'backgroundDrift' },
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
const config = this.#config;
|
|
2400
|
+
const owner = this.#newId();
|
|
2401
|
+
return startWatch(
|
|
2402
|
+
{
|
|
2403
|
+
...this.#backgroundDeps(owner),
|
|
2404
|
+
watchStore: this.#watchStore(),
|
|
2405
|
+
watchLockFor: (collection) =>
|
|
2406
|
+
new MigrationLock(db, config.lockCollection, config.lockTTLSeconds, {
|
|
2407
|
+
id: `watch:${collection}`,
|
|
2408
|
+
label: 'drift watcher lock',
|
|
2409
|
+
}),
|
|
2410
|
+
owner,
|
|
2411
|
+
onDrift: config.backgroundOnDrift,
|
|
2412
|
+
},
|
|
2413
|
+
options,
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* What the live drift watchers recorded — one row per watched collection
|
|
2419
|
+
* (or the one asked for, `null` when it has none): state, leader, counters,
|
|
2420
|
+
* last event. Never the resume token.
|
|
2421
|
+
* @experimental
|
|
2422
|
+
*/
|
|
2423
|
+
async backgroundWatchStatus(collection) {
|
|
2424
|
+
await this.#backgroundReady();
|
|
2425
|
+
const rows = await this.#watchStore().status(collection);
|
|
2426
|
+
if (collection !== undefined) return rows === null ? null : watchRow(rows);
|
|
2427
|
+
const views = [];
|
|
2428
|
+
for (const row of rows) views.push(watchRow(row));
|
|
2429
|
+
return views;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
#watchStore() {
|
|
2433
|
+
this.#watchStoreInstance ??= new BackgroundWatchStore(
|
|
2434
|
+
this.#requireDb(),
|
|
2435
|
+
this.#config.backgroundCollection,
|
|
2436
|
+
);
|
|
2437
|
+
return this.#watchStoreInstance;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* The drift watch, once: old-shape documents that appeared after a
|
|
2442
|
+
* background migration completed are found with one indexed probe each,
|
|
2443
|
+
* and reopen it (`onDrift: 'reopen'`, the `backgroundOnDrift` default) or
|
|
2444
|
+
* are only reported (`'report'`). `collections` narrows it.
|
|
2445
|
+
* @experimental
|
|
2446
|
+
*/
|
|
2447
|
+
async verifyBackground(options = {}) {
|
|
2448
|
+
await this.#backgroundReady();
|
|
2449
|
+
const onDrift = options.onDrift ?? this.#config.backgroundOnDrift;
|
|
2450
|
+
if (onDrift !== 'reopen' && onDrift !== 'report') {
|
|
2451
|
+
throw new ConfigInvalidError("onDrift must be 'reopen' or 'report'", { onDrift });
|
|
2452
|
+
}
|
|
2453
|
+
// With `backgroundDrift: 'stream'` the poll is the safety net: it leaves
|
|
2454
|
+
// alone a collection whose watcher is streaming and alive.
|
|
2455
|
+
const streaming =
|
|
2456
|
+
this.#config.backgroundDrift === 'stream' ? await this.#streamingCollections() : undefined;
|
|
2457
|
+
return verifyDrift(this.#backgroundDeps(), {
|
|
2458
|
+
onDrift,
|
|
2459
|
+
...(options.collections !== undefined ? { collections: options.collections } : {}),
|
|
2460
|
+
...(streaming !== undefined ? { streaming } : {}),
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/** Collections a live watcher leads right now — streaming, its record fresh */
|
|
2465
|
+
async #streamingCollections() {
|
|
2466
|
+
const fresh = Date.now() - STREAMING_FRESH_MS;
|
|
2467
|
+
const names = [];
|
|
2468
|
+
for (const row of await this.#watchStore().status()) {
|
|
2469
|
+
if (row.state === 'streaming' && row.updatedAt?.getTime() > fresh) names.push(row._id);
|
|
2470
|
+
}
|
|
2471
|
+
return names;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* How drift is watched (`backgroundDrift`): `'poll'`, `'stream'` or
|
|
2476
|
+
* `'both'` — what a runner or a queue worker hosting this kit follows.
|
|
2477
|
+
* Resolves the config; does not connect.
|
|
2478
|
+
*/
|
|
2479
|
+
async driftMode() {
|
|
2480
|
+
return (await this.#ensureConfig()).backgroundDrift;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/** A background migration file, loaded and resolved: `{ spec, fns, checksum }` */
|
|
2484
|
+
async #loadBackground(name, { tolerant = false } = {}) {
|
|
2485
|
+
assertMigrationName(name);
|
|
2486
|
+
const filepath = this.#filepath(name);
|
|
2487
|
+
// Once per version of the file: lanes ask on every slice, and a re-import
|
|
2488
|
+
// per slice (under reloadMigrations) is a module the cache never frees.
|
|
2489
|
+
// A file that cannot be read is loaded for its error.
|
|
2490
|
+
const loaded =
|
|
2491
|
+
(await this.#peek(name)) ??
|
|
2492
|
+
(await loadMigrationFile(filepath, {
|
|
2493
|
+
reload: this.#config.reloadMigrations,
|
|
2494
|
+
}));
|
|
2495
|
+
if (loaded.kind !== 'background') {
|
|
2496
|
+
throw new MigrationInvalidExportError(`${name} is not a background migration`, { name });
|
|
2497
|
+
}
|
|
2498
|
+
const { spec, fns } = await this.#backgroundSpec(name, loaded, { tolerant });
|
|
2499
|
+
return { spec, fns, checksum: await this.#cachedChecksum(filepath) };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/** After one completes: the blocked ones that required it, unblocked if nothing else holds them */
|
|
2503
|
+
async #unblockDependents(name) {
|
|
2504
|
+
const store = this.#backgroundStore();
|
|
2505
|
+
const deps = this.#backgroundDeps();
|
|
2506
|
+
for (const state of await store.list({ requires: name, status: 'blocked' })) {
|
|
2507
|
+
await tryUnblock(deps, state);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* A migration module read ahead of its run — for its `requires` and its
|
|
2513
|
+
* kind — cached by checksum, so a long-lived kit imports an edited file
|
|
2514
|
+
* again and an unchanged one never twice. `null` when it cannot be read.
|
|
2515
|
+
*/
|
|
2516
|
+
async #peek(name) {
|
|
2517
|
+
let checksum;
|
|
2518
|
+
try {
|
|
2519
|
+
checksum = await this.#cachedChecksum(this.#filepath(name));
|
|
2520
|
+
} catch {
|
|
2521
|
+
return null;
|
|
2522
|
+
}
|
|
2523
|
+
const cached = this.#moduleCache.get(name);
|
|
2524
|
+
if (cached?.checksum === checksum) return cached.loaded;
|
|
2525
|
+
const loaded = await loadMigrationFile(this.#filepath(name), {
|
|
2526
|
+
reload: this.#config.reloadMigrations,
|
|
2527
|
+
});
|
|
2528
|
+
this.#moduleCache.set(name, { checksum, loaded });
|
|
2529
|
+
return loaded;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* The background migrations of `requires` not done yet:
|
|
2534
|
+
* `[{ migration, status }]` — background-kit.js.
|
|
2535
|
+
*/
|
|
2536
|
+
#unsatisfied(requires, options) {
|
|
2537
|
+
return unsatisfied(this.#backgroundHost(), requires, options);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* The background migrations a pending regular migration waits for — run
|
|
2542
|
+
* first, under this run's lock, in inline mode. Empty when it may run.
|
|
2543
|
+
*/
|
|
2544
|
+
async #requiresGuard(name, signal) {
|
|
2545
|
+
const loaded = await this.#peek(name);
|
|
2546
|
+
if (loaded === null || loaded.kind === 'background') return [];
|
|
2547
|
+
const requires = loaded.requires ?? [];
|
|
2548
|
+
if (requires.length === 0) return [];
|
|
2549
|
+
await this.#assertRequiresAreBackground(name, requires);
|
|
2550
|
+
await this.#backgroundIndexes();
|
|
2551
|
+
let waiting = await this.#unsatisfied(requires);
|
|
2552
|
+
if (waiting.length > 0 && this.#config.backgroundInline) {
|
|
2553
|
+
for (const entry of waiting) {
|
|
2554
|
+
if (entry.status !== 'unregistered') await this.#runInline(entry.migration, signal);
|
|
2555
|
+
}
|
|
2556
|
+
waiting = await this.#unsatisfied(requires);
|
|
2557
|
+
}
|
|
2558
|
+
return waiting;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/**
|
|
2562
|
+
* Inline mode: drive a background migration to the end right here, under
|
|
2563
|
+
* the run's lock — what it requires first.
|
|
2564
|
+
*
|
|
2565
|
+
* @throws {BackgroundFailedError} when it fails; RunAbortedError on stop()
|
|
2566
|
+
*/
|
|
2567
|
+
async #runInline(name, signal) {
|
|
2568
|
+
const state = await this.#backgroundStore().get(name);
|
|
2569
|
+
if (state === null) return;
|
|
2570
|
+
for (const required of state.status === 'blocked' ? (state.waitsFor ?? []) : []) {
|
|
2571
|
+
await this.#runInline(required, signal);
|
|
2572
|
+
}
|
|
2573
|
+
this.#logger.info(
|
|
2574
|
+
`⧗ Running ${name} inline`,
|
|
2575
|
+
this.#fields({ background: name, inline: true }),
|
|
2576
|
+
);
|
|
2577
|
+
await this.#backgroundReady(name, { lanes: true });
|
|
2578
|
+
const status = await drive(this.#backgroundHost(), name, {
|
|
2579
|
+
signal,
|
|
2580
|
+
concurrency: state.spec?.maxParallel ?? 1,
|
|
2581
|
+
inline: true,
|
|
2582
|
+
});
|
|
2583
|
+
if (status.status === 'blocked') {
|
|
2584
|
+
throw new BackgroundPendingError(
|
|
2585
|
+
`Background migration ${name} cannot run inline: it waits for ${status.waitsFor.join(', ')}`,
|
|
2586
|
+
{ migration: name, waitsFor: status.waitsFor.map((migration) => ({ migration })) },
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Config and connection for a background method — no run, no migration
|
|
2593
|
+
* lock. `lanes`: it claims partitions, and needs the slot indexes.
|
|
2594
|
+
*/
|
|
2595
|
+
async #backgroundReady(name, { lanes = false } = {}) {
|
|
2596
|
+
if (name !== undefined) assertMigrationName(name);
|
|
2597
|
+
await this.#ensureConfig();
|
|
2598
|
+
await this.connect();
|
|
2599
|
+
await this.#backgroundIndexes({ lanes });
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* The background collections' indexes, created on first use — unless
|
|
2604
|
+
* `ensureIndexes: false` (a user who cannot create indexes): then they are
|
|
2605
|
+
* expected to exist, and only what claims partitions checks they do.
|
|
2606
|
+
*/
|
|
2607
|
+
async #backgroundIndexes({ lanes = false } = {}) {
|
|
2608
|
+
const store = this.#backgroundStore();
|
|
2609
|
+
if (this.#config.ensureIndexes) await store.ensureIndexes();
|
|
2610
|
+
else if (lanes) await store.assertIndexes();
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/** The state, or NotAppliedError — a control action needs a registered background migration */
|
|
2614
|
+
async #registered(name) {
|
|
2615
|
+
const state = await this.#backgroundStore().get(name);
|
|
2616
|
+
if (state === null) {
|
|
2617
|
+
throw new NotAppliedError(`Background migration ${name} is not registered — run up first`, {
|
|
2618
|
+
migration: name,
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
return state;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* One coordinator step for a background migration — see background.js.
|
|
2626
|
+
* Reentrant: no run, no migration lock; serialized by its own lock.
|
|
2627
|
+
* @experimental
|
|
2628
|
+
*/
|
|
2629
|
+
async coordinateBackground(name, { signal, driver } = {}) {
|
|
2630
|
+
await this.#backgroundReady(name);
|
|
2631
|
+
return coordinate(this.#backgroundDeps(this.#newId()), name, {
|
|
2632
|
+
signal,
|
|
2633
|
+
...(driver ? { driver } : {}),
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* One slice of one lane of a background migration: claim a partition and a
|
|
2639
|
+
* slot, work it until `sliceMs` (the spec's by default) runs out, release.
|
|
2640
|
+
* @experimental
|
|
2641
|
+
*/
|
|
2642
|
+
async runBackgroundSlice(name, { signal, sliceMs } = {}) {
|
|
2643
|
+
if (sliceMs !== undefined) assertSliceMs(sliceMs);
|
|
2644
|
+
await this.#backgroundReady(name, { lanes: true });
|
|
2645
|
+
const owner = this.#newId();
|
|
2646
|
+
return runSlice(this.#backgroundDeps(owner), name, { signal, sliceMs, owner });
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Drive a background migration from this process — the coordinator and up
|
|
2651
|
+
* to `concurrency` lanes (at most its `maxParallel`) — until it is done
|
|
2652
|
+
* (`untilDone`, the default) or for one round. Resolves to its status; a
|
|
2653
|
+
* failed one throws BackgroundFailedError, a stop RunAbortedError (the
|
|
2654
|
+
* background migration itself goes on from where it was).
|
|
2655
|
+
* @experimental
|
|
2656
|
+
*/
|
|
2657
|
+
async runBackground(name, { signal, sliceMs, untilDone = true, concurrency = 1 } = {}) {
|
|
2658
|
+
await this.#backgroundReady(name, { lanes: true });
|
|
2659
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
2660
|
+
throw new ConfigInvalidError('concurrency must be a positive integer', { concurrency });
|
|
2661
|
+
}
|
|
2662
|
+
if (sliceMs !== undefined) assertSliceMs(sliceMs);
|
|
2663
|
+
return drive(this.#backgroundHost(), name, { signal, sliceMs, untilDone, concurrency });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/**
|
|
2667
|
+
* The status of one background migration (`null` when not registered), or
|
|
2668
|
+
* of every one when no name is given.
|
|
2669
|
+
* @experimental
|
|
2670
|
+
*/
|
|
2671
|
+
async backgroundStatus(name) {
|
|
2672
|
+
await this.#backgroundReady(name);
|
|
2673
|
+
const store = this.#backgroundStore();
|
|
2674
|
+
if (name !== undefined) {
|
|
2675
|
+
const state = await store.get(name);
|
|
2676
|
+
return state === null ? null : stateView(store, state);
|
|
2677
|
+
}
|
|
2678
|
+
const views = [];
|
|
2679
|
+
for (const state of await store.list()) views.push(await stateView(store, state));
|
|
2680
|
+
return views;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/**
|
|
2684
|
+
* The partitions of a background migration's latest generation — scope,
|
|
2685
|
+
* cursor, counters and lease holder (never its token).
|
|
2686
|
+
* @experimental
|
|
2687
|
+
*/
|
|
2688
|
+
async backgroundPartitions(name) {
|
|
2689
|
+
await this.#backgroundReady(name);
|
|
2690
|
+
const state = await this.#registered(name);
|
|
2691
|
+
const partitions = await this.#backgroundStore().partitions(name, {
|
|
2692
|
+
generation: state.generation,
|
|
2693
|
+
});
|
|
2694
|
+
const views = [];
|
|
2695
|
+
for (const partition of partitions) views.push(partitionView(partition));
|
|
2696
|
+
return views;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
/**
|
|
2700
|
+
* The background migrations with work to do — blocked ones whose requires
|
|
2701
|
+
* are met are unblocked on the way. `[{ migration, status }]`, oldest first.
|
|
2702
|
+
* @experimental
|
|
2703
|
+
*/
|
|
2704
|
+
async runnableBackground() {
|
|
2705
|
+
await this.#backgroundReady();
|
|
2706
|
+
return runnable(this.#backgroundHost());
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/** A control action, after the checks every one shares */
|
|
2710
|
+
async #controlBackground(name, action, options = {}) {
|
|
2711
|
+
// Who and why go into the state's history: held to the changelog's limits.
|
|
2712
|
+
assertActorValid(options);
|
|
2713
|
+
await this.#backgroundReady(name);
|
|
2714
|
+
await this.#registered(name);
|
|
2715
|
+
const deps = this.#backgroundDeps();
|
|
2716
|
+
const result = await controlBackground(deps, name, action, options);
|
|
2717
|
+
if (options.wait === true && (action === 'pause' || action === 'cancel')) {
|
|
2718
|
+
result.stopped = await waitForLanes(deps, name, { signal: options.signal });
|
|
2719
|
+
}
|
|
2720
|
+
return result;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/** Pause a background migration; its lanes stop at the next batch. `wait` until they have. @experimental */
|
|
2724
|
+
pauseBackground(name, options = {}) {
|
|
2725
|
+
return this.#controlBackground(name, 'pause', options);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/** Resume a paused background migration. @experimental */
|
|
2729
|
+
resumeBackground(name, options = {}) {
|
|
2730
|
+
return this.#controlBackground(name, 'resume', options);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
/** Cancel a background migration; `wait` until its lanes have stopped. @experimental */
|
|
2734
|
+
cancelBackground(name, options = {}) {
|
|
2735
|
+
return this.#controlBackground(name, 'cancel', options);
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Retry a failed or cancelled background migration — the same generation,
|
|
2740
|
+
* or `fromStart`; `repin` pins the file on disk first. A completed one is
|
|
2741
|
+
* reopened over whatever is left.
|
|
2742
|
+
* @experimental
|
|
2743
|
+
*/
|
|
2744
|
+
async retryBackground(name, options = {}) {
|
|
2745
|
+
if (options.repin === true) await this.repinBackground(name, options);
|
|
2746
|
+
return this.#controlBackground(name, 'retry', options);
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/**
|
|
2750
|
+
* Pin the file on disk as the background migration's version — its
|
|
2751
|
+
* checksum (in the changelog too, so a strict drift check agrees) and its
|
|
2752
|
+
* spec. A change to what it matches or how it splits means a new plan.
|
|
2753
|
+
* @experimental
|
|
2754
|
+
*/
|
|
2755
|
+
async repinBackground(name, options = {}) {
|
|
2756
|
+
assertActorValid(options);
|
|
2757
|
+
await this.#backgroundReady(name);
|
|
2758
|
+
await this.#registered(name);
|
|
2759
|
+
const result = await repinBackgroundState(this.#backgroundDeps(), name, options);
|
|
2760
|
+
await this.#requireChangelog().setChecksum(this.#requireDb(), name, result.checksum);
|
|
2761
|
+
return result;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Dry-run a background migration — registered or not — with nothing
|
|
2766
|
+
* written: on a sample (`sample` random documents, or the `first` n), its
|
|
2767
|
+
* transformation alone; with `validate`, the real write path in a
|
|
2768
|
+
* transaction that is always aborted. A step migration runs `steps` steps
|
|
2769
|
+
* in that transaction instead.
|
|
2770
|
+
* @experimental
|
|
2771
|
+
*/
|
|
2772
|
+
async dryRunBackground(name, options = {}) {
|
|
2773
|
+
await this.#ensureConfig();
|
|
2774
|
+
await this.connect();
|
|
2775
|
+
const loaded = await this.#loadBackground(name);
|
|
2776
|
+
const db = this.#requireDb();
|
|
2777
|
+
const deps = {
|
|
2778
|
+
db,
|
|
2779
|
+
client: this.#client,
|
|
2780
|
+
logger: this.#logger,
|
|
2781
|
+
forbidden: this.#bookkeepingNames(),
|
|
2782
|
+
topology: () => this.#serverTopology(db),
|
|
2783
|
+
// The sandbox refuses a sharded collection's distinct (a mongos cannot run it in a transaction).
|
|
2784
|
+
shardKeyOf: (collection) => readShardKey(this.#client, db.databaseName, collection),
|
|
2785
|
+
};
|
|
2786
|
+
if (loaded.spec.mode === 'step' || options.steps !== undefined) {
|
|
2787
|
+
if (loaded.spec.mode !== 'step') {
|
|
2788
|
+
throw new ConfigInvalidError(
|
|
2789
|
+
`${name} is declarative — dry-run it on a sample (sample, first), not by steps`,
|
|
2790
|
+
{ migration: name },
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
// From where the background migration is, unless asked to start over.
|
|
2794
|
+
let checkpoint = null;
|
|
2795
|
+
const state = await this.#backgroundStore().get(name);
|
|
2796
|
+
if (state !== null && !options.fromStart) {
|
|
2797
|
+
const [partition] = await this.#backgroundStore().partitions(name, {
|
|
2798
|
+
generation: state.generation,
|
|
2799
|
+
});
|
|
2800
|
+
checkpoint = partition?.cursor?.checkpoint ?? null;
|
|
2801
|
+
}
|
|
2802
|
+
return previewSteps(deps, name, loaded, { ...options, checkpoint });
|
|
2803
|
+
}
|
|
2804
|
+
return previewSample(deps, name, loaded, options);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* Clear a background migration's coordinator lock and every partition
|
|
2809
|
+
* lease — for a stuck one; live lanes are fenced off at their next write.
|
|
2810
|
+
* @experimental
|
|
2811
|
+
*/
|
|
2812
|
+
async unlockBackground(name) {
|
|
2813
|
+
await this.#backgroundReady(name);
|
|
2814
|
+
const deps = this.#backgroundDeps();
|
|
2815
|
+
const lock = (await deps.lockFor(name).forceRelease()) !== null;
|
|
2816
|
+
const leases = await deps.store.unlockAll(name);
|
|
2817
|
+
// It fences every live lane: an operator's act, recorded like any control.
|
|
2818
|
+
await deps.store.note(name, { action: 'unlock', lock, leases });
|
|
2819
|
+
this.#emit('background:control', { migration: name, action: 'unlock', lock, leases });
|
|
2820
|
+
this.#logger.warn(
|
|
2821
|
+
`⚠ ${name}: unlocked — coordinator lock ${lock ? 'cleared' : 'not held'}, ${leases} ` +
|
|
2822
|
+
'lease(s) dropped (their lanes stop at their next write)',
|
|
2823
|
+
this.#fields({ background: name, lock, leases }),
|
|
2824
|
+
);
|
|
2825
|
+
return { lock, leases };
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
/** How converge treats search indexes: the config, and a call's own `waitForSearchIndexes` */
|
|
2829
|
+
#convergeSearchOptions(options = {}) {
|
|
2830
|
+
const config = this.#config;
|
|
2831
|
+
return {
|
|
2832
|
+
onUnavailable: config.onSearchUnavailable,
|
|
2833
|
+
wait: options.waitForSearchIndexes ?? config.waitForSearchIndexes,
|
|
2834
|
+
waitTimeoutMs: config.searchIndexWaitTimeoutMs,
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/** What runConverge works with; `lock` (a run's) lets it give the lock up before waiting */
|
|
2839
|
+
#convergeDeps(lock) {
|
|
2840
|
+
const db = this.#requireDb();
|
|
2841
|
+
return {
|
|
2842
|
+
db,
|
|
2843
|
+
...(lock ? { releaseLock: () => lock.release() } : {}),
|
|
2844
|
+
recordSearchWait: (waitedMs, outcome) => this.#telemetry.searchWaited({ waitedMs, outcome }),
|
|
1919
2845
|
logger: this.#logger,
|
|
1920
2846
|
fields: (extra) => this.#fields(extra),
|
|
1921
2847
|
emit: (event, payload) => this.#emit(event, payload),
|
|
@@ -1928,14 +2854,13 @@ class MigratorKit extends EventEmitter {
|
|
|
1928
2854
|
environment: this.#environment(),
|
|
1929
2855
|
}),
|
|
1930
2856
|
record: (entry) => this.#convergeLog().append(db, entry),
|
|
1931
|
-
// Behind a mongos only: the shard key
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
)?.key,
|
|
2857
|
+
// Behind a mongos only: the shard key — so prune never drops its index,
|
|
2858
|
+
// and the version index takes it as a prefix. An 8.0 `unsplittable`
|
|
2859
|
+
// collection is not sharded; `undefined` when config may not be read.
|
|
2860
|
+
shardKeyOf: async (name) => {
|
|
2861
|
+
const sharding = await readShardKey(this.#client, db.databaseName, name);
|
|
2862
|
+
return sharding === undefined ? undefined : (sharding?.key ?? null);
|
|
2863
|
+
},
|
|
1939
2864
|
};
|
|
1940
2865
|
}
|
|
1941
2866
|
|