@alexify/migronaut 2.3.0 → 2.4.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 +83 -0
- package/README.md +8 -1
- package/bullmq.d.ts +35 -2
- package/index.d.ts +257 -3
- package/package.json +2 -1
- package/src/bullmq/background-processor.js +77 -5
- package/src/bullmq/processor.js +221 -8
- package/src/bullmq/service.js +4 -0
- package/src/core/background-dry-run.js +9 -0
- package/src/core/background-engine.js +47 -16
- package/src/core/background-kit.js +18 -11
- package/src/core/background-watch.js +5 -0
- package/src/core/background.js +6 -0
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +129 -16
- package/src/core/options.js +20 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/runner.js +33 -7
- package/src/utils/job-ref.js +44 -0
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +3 -0
package/src/core/migrator.js
CHANGED
|
@@ -26,6 +26,7 @@ const { computeChecksum } = require('../utils/checksum.js');
|
|
|
26
26
|
const { mapLimit } = require('../utils/concurrency.js');
|
|
27
27
|
const { errorText, errorWithCause } = require('../utils/error.js');
|
|
28
28
|
const { createIdGenerator } = require('../utils/id.js');
|
|
29
|
+
const { jobFields } = require('../utils/job-ref.js');
|
|
29
30
|
const { loadMigrationFile } = require('../utils/loader.js');
|
|
30
31
|
const { resolveLogger } = require('../utils/logger.js');
|
|
31
32
|
const { assertMigrationName } = require('../utils/migration-name.js');
|
|
@@ -77,6 +78,7 @@ const { readChunks, readShardKey } = require('./shard-info.js');
|
|
|
77
78
|
|
|
78
79
|
const { runImport } = require('./import-runner.js');
|
|
79
80
|
const { MigrationLock, runWithLock, toLockInfo } = require('./lock.js');
|
|
81
|
+
const { MIGRATION_LOG_EVENT, backgroundLogs, createRunLog } = require('./migration-logger.js');
|
|
80
82
|
const {
|
|
81
83
|
assertActorValid,
|
|
82
84
|
assertConvergeOptions,
|
|
@@ -85,6 +87,7 @@ const {
|
|
|
85
87
|
assertFilename,
|
|
86
88
|
assertHistoryLimit,
|
|
87
89
|
assertImportOptions,
|
|
90
|
+
assertJobValid,
|
|
88
91
|
assertListOptions,
|
|
89
92
|
assertRedoOptions,
|
|
90
93
|
assertUpOptions,
|
|
@@ -137,6 +140,14 @@ class MigratorKit extends EventEmitter {
|
|
|
137
140
|
#runSetupDepth = 0;
|
|
138
141
|
/** Correlation id for the run in flight — ties logs, lock and changelog together */
|
|
139
142
|
#runId;
|
|
143
|
+
/**
|
|
144
|
+
* The log side of the run in flight (migration-logger's `createRunLog`): its
|
|
145
|
+
* job and actor, `ctx.run`, `ctx.logger`, the `migration:log` counter. Set
|
|
146
|
+
* and cleared with #runId; a logger keeps what it was made with.
|
|
147
|
+
*/
|
|
148
|
+
#runLog;
|
|
149
|
+
/** The `migration:log` channel handed to migration loggers — made once */
|
|
150
|
+
#logEmitterInstance;
|
|
140
151
|
/** Mints an id in the configured format (`generateId`, else a UUID); set with the config */
|
|
141
152
|
#newId;
|
|
142
153
|
/** Spans and metrics through the injected `telemetry` (a no-op without one); set with the config */
|
|
@@ -191,7 +202,11 @@ class MigratorKit extends EventEmitter {
|
|
|
191
202
|
#moduleCache = new Map();
|
|
192
203
|
|
|
193
204
|
constructor(config = {}, options = {}) {
|
|
194
|
-
|
|
205
|
+
// An async listener whose promise rejects (a subscriber's insert that
|
|
206
|
+
// failed) would otherwise surface as an unhandledRejection, which ends the
|
|
207
|
+
// process — captureRejections routes it to the method below instead, so it
|
|
208
|
+
// is contained the way #emit contains a listener that throws.
|
|
209
|
+
super({ captureRejections: true });
|
|
195
210
|
this.#partialConfig = config;
|
|
196
211
|
this.#configPath = options.configPath;
|
|
197
212
|
this.#progress = options.progress;
|
|
@@ -208,8 +223,13 @@ class MigratorKit extends EventEmitter {
|
|
|
208
223
|
* failure into a second, unrelated one.
|
|
209
224
|
*/
|
|
210
225
|
#emit(event, payload) {
|
|
226
|
+
this.#deliver(event, { ...(this.#runId ? { runId: this.#runId } : {}), ...payload });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** {@link #emit} without the run id stamp: for a payload that carries its own */
|
|
230
|
+
#deliver(event, payload) {
|
|
211
231
|
try {
|
|
212
|
-
this.emit(event,
|
|
232
|
+
this.emit(event, payload);
|
|
213
233
|
} catch (error) {
|
|
214
234
|
// A listener's failure is its own problem — but an invisible one is
|
|
215
235
|
// undebuggable, so leave a trace at debug level.
|
|
@@ -220,6 +240,23 @@ class MigratorKit extends EventEmitter {
|
|
|
220
240
|
}
|
|
221
241
|
}
|
|
222
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Where a listener's rejected promise lands (see the constructor): left at
|
|
245
|
+
* debug level like a listener that throws, never re-emitted as `error` —
|
|
246
|
+
* with no `error` listener that would throw, from a tick no one awaits.
|
|
247
|
+
*/
|
|
248
|
+
[EventEmitter.captureRejectionSymbol](error, event) {
|
|
249
|
+
try {
|
|
250
|
+
const name = String(event);
|
|
251
|
+
this.#logger.debug(
|
|
252
|
+
`Event listener for '${name}' rejected: ${errorText(error)}`,
|
|
253
|
+
this.#fields({ event: name, error: errorText(error) }),
|
|
254
|
+
);
|
|
255
|
+
} catch {
|
|
256
|
+
// Reporting a listener's failure must not become a failure of its own.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
223
260
|
/**
|
|
224
261
|
* Stop the run in progress: the migration currently executing is allowed to
|
|
225
262
|
* finish (interrupting it mid-write is what leaves a database half-migrated),
|
|
@@ -324,10 +361,11 @@ class MigratorKit extends EventEmitter {
|
|
|
324
361
|
* Structured fields for a log line. Passed as the logger's second argument
|
|
325
362
|
* (first, for pino-style loggers) so a machine-readable sink gets
|
|
326
363
|
* `{migration, direction, durationMs, …}` instead of having to parse the
|
|
327
|
-
* emoji-prefixed human string.
|
|
364
|
+
* emoji-prefixed human string. A run's lines carry its id and, when the
|
|
365
|
+
* caller named one, its queue job (`jobId`, `groupId`).
|
|
328
366
|
*/
|
|
329
367
|
#fields(extra) {
|
|
330
|
-
return this.#runId ? { runId: this.#runId, ...extra } : { ...extra };
|
|
368
|
+
return this.#runId ? { runId: this.#runId, ...this.#runLog?.fields, ...extra } : { ...extra };
|
|
331
369
|
}
|
|
332
370
|
|
|
333
371
|
/** Record a finished wait for the lock — see {@link RECORD_LOCK_WAIT} */
|
|
@@ -485,6 +523,13 @@ class MigratorKit extends EventEmitter {
|
|
|
485
523
|
// before any other run state exists: a `generateId` that throws or returns
|
|
486
524
|
// a non-id rejects here, leaving nothing to unwind and no event emitted.
|
|
487
525
|
this.#runId = this.#newId();
|
|
526
|
+
this.#runLog = createRunLog({
|
|
527
|
+
id: this.#runId,
|
|
528
|
+
job: options.job,
|
|
529
|
+
actor: pickActor(options),
|
|
530
|
+
sink: this.#logger,
|
|
531
|
+
emitter: this.#logEmitter(),
|
|
532
|
+
});
|
|
488
533
|
// A second controller layered over the lock's own signal, so stop() and a
|
|
489
534
|
// lost lock abort through the same path the run loops already watch.
|
|
490
535
|
const stopper = new AbortController();
|
|
@@ -502,6 +547,7 @@ class MigratorKit extends EventEmitter {
|
|
|
502
547
|
const recorder = new RunRecorder({
|
|
503
548
|
info,
|
|
504
549
|
runId: this.#runId,
|
|
550
|
+
job: this.#runLog.fields,
|
|
505
551
|
telemetry: this.#telemetry,
|
|
506
552
|
emit: (event, payload) => this.#emit(event, payload),
|
|
507
553
|
logger: this.#logger,
|
|
@@ -544,9 +590,23 @@ class MigratorKit extends EventEmitter {
|
|
|
544
590
|
recorder.finish(result, failure);
|
|
545
591
|
this.#abort = undefined;
|
|
546
592
|
this.#runId = undefined;
|
|
593
|
+
this.#runLog = undefined;
|
|
547
594
|
}
|
|
548
595
|
}
|
|
549
596
|
|
|
597
|
+
/**
|
|
598
|
+
* The kit's side of `migration:log` for migration loggers: `wanted` spares
|
|
599
|
+
* them the copy of a payload no one listens to. A payload names its own run
|
|
600
|
+
* (a late call's is not the run in flight), so it is delivered as it is.
|
|
601
|
+
*/
|
|
602
|
+
#logEmitter() {
|
|
603
|
+
this.#logEmitterInstance ??= {
|
|
604
|
+
wanted: () => this.listenerCount(MIGRATION_LOG_EVENT) > 0,
|
|
605
|
+
emit: (payload) => this.#deliver(MIGRATION_LOG_EVENT, payload),
|
|
606
|
+
};
|
|
607
|
+
return this.#logEmitterInstance;
|
|
608
|
+
}
|
|
609
|
+
|
|
550
610
|
/**
|
|
551
611
|
* Attach partial results to an error's context. Copy-on-write: the error may
|
|
552
612
|
* live on a shared abort signal (see #assertNotAborted) or already carry
|
|
@@ -781,8 +841,12 @@ class MigratorKit extends EventEmitter {
|
|
|
781
841
|
const results = [];
|
|
782
842
|
let doneCount = 0;
|
|
783
843
|
let failure;
|
|
844
|
+
// The hooks around the whole run see the run's correlation, not one
|
|
845
|
+
// migration's: no migration name, no attempt.
|
|
846
|
+
const run = this.#runLog.info(direction);
|
|
847
|
+
const runContext = { ...context, run, logger: this.#runLog.logger(run) };
|
|
784
848
|
try {
|
|
785
|
-
await this.#runHook(config.hooks?.beforeAll, 'beforeAll', [
|
|
849
|
+
await this.#runHook(config.hooks?.beforeAll, 'beforeAll', [runContext]);
|
|
786
850
|
for (const [index, name] of names.entries()) {
|
|
787
851
|
// Between migrations is the only safe place to stop: the one in flight
|
|
788
852
|
// has committed, and the next has not started.
|
|
@@ -808,7 +872,7 @@ class MigratorKit extends EventEmitter {
|
|
|
808
872
|
// the diagnosis the caller needs, not the notification hook's own trouble.
|
|
809
873
|
try {
|
|
810
874
|
await this.#runHook(config.hooks?.afterAll, 'afterAll', [
|
|
811
|
-
|
|
875
|
+
runContext,
|
|
812
876
|
{ success: succeeded, applied: doneCount, direction },
|
|
813
877
|
]);
|
|
814
878
|
} catch (hookError) {
|
|
@@ -876,10 +940,15 @@ class MigratorKit extends EventEmitter {
|
|
|
876
940
|
const config = this.#config;
|
|
877
941
|
const logger = this.#logger;
|
|
878
942
|
const batchField = batch !== undefined ? { batch } : {};
|
|
943
|
+
// What each attempt adds to the context — ctx.run and ctx.logger — made
|
|
944
|
+
// anew per attempt, so a retried transaction's body knows it is one.
|
|
945
|
+
const runLog = this.#runLog;
|
|
946
|
+
const attemptContext = (attempt) =>
|
|
947
|
+
runLog.attempt(direction, { migration: name, batch, attempt });
|
|
879
948
|
|
|
880
949
|
await this.#runHook(config.hooks?.beforeEach, 'beforeEach', [
|
|
881
950
|
name,
|
|
882
|
-
context,
|
|
951
|
+
{ ...context, ...attemptContext(1) },
|
|
883
952
|
{ direction, index, total },
|
|
884
953
|
]);
|
|
885
954
|
const loaded = await loadMigrationFile(this.#filepath(name), {
|
|
@@ -899,23 +968,28 @@ class MigratorKit extends EventEmitter {
|
|
|
899
968
|
try {
|
|
900
969
|
// The changelog write happens inside runMigration so that, under
|
|
901
970
|
// useTransaction, it commits atomically with the migration itself.
|
|
902
|
-
const { duration } = await runMigration({
|
|
971
|
+
const { duration, attempts } = await runMigration({
|
|
903
972
|
name,
|
|
904
973
|
migration,
|
|
905
974
|
direction,
|
|
906
975
|
context,
|
|
907
976
|
useTransaction,
|
|
908
977
|
logger,
|
|
978
|
+
attemptContext,
|
|
909
979
|
...(config.timeoutMs ? { timeoutMs: config.timeoutMs } : {}),
|
|
910
980
|
onSuccess: (elapsed, session) => onSuccess(migration, elapsed, session),
|
|
911
981
|
...(config.hooks ? { hooks: config.hooks } : {}),
|
|
912
982
|
});
|
|
913
983
|
this.#progress?.onStop('success');
|
|
984
|
+
span.set({ [ATTRIBUTES.MIGRATION_ATTEMPTS]: attempts });
|
|
985
|
+
// A transaction the driver retried ran the body again: say how often.
|
|
986
|
+
const retried = attempts > 1 ? { attempts } : {};
|
|
914
987
|
this.#emit('migration:success', {
|
|
915
988
|
migration: name,
|
|
916
989
|
direction,
|
|
917
990
|
...batchField,
|
|
918
991
|
durationMs: duration,
|
|
992
|
+
...retried,
|
|
919
993
|
});
|
|
920
994
|
const label =
|
|
921
995
|
migration.kind === 'background'
|
|
@@ -927,7 +1001,13 @@ class MigratorKit extends EventEmitter {
|
|
|
927
1001
|
: '↩ Reverted';
|
|
928
1002
|
logger.info(
|
|
929
1003
|
`${label} ${name} [${duration}ms]`,
|
|
930
|
-
this.#fields({
|
|
1004
|
+
this.#fields({
|
|
1005
|
+
migration: name,
|
|
1006
|
+
direction,
|
|
1007
|
+
...batchField,
|
|
1008
|
+
durationMs: duration,
|
|
1009
|
+
...retried,
|
|
1010
|
+
}),
|
|
931
1011
|
);
|
|
932
1012
|
if (migration.registered) {
|
|
933
1013
|
this.#emit('background:registered', { migration: name, ...migration.registered });
|
|
@@ -941,7 +1021,7 @@ class MigratorKit extends EventEmitter {
|
|
|
941
1021
|
await this.#runHook(config.hooks?.afterEach, 'afterEach', [
|
|
942
1022
|
name,
|
|
943
1023
|
duration,
|
|
944
|
-
context,
|
|
1024
|
+
{ ...context, ...attemptContext(attempts) },
|
|
945
1025
|
{ direction, index, total },
|
|
946
1026
|
]);
|
|
947
1027
|
return duration;
|
|
@@ -956,6 +1036,13 @@ class MigratorKit extends EventEmitter {
|
|
|
956
1036
|
? error.context.durationMs
|
|
957
1037
|
: undefined;
|
|
958
1038
|
const durationField = durationMs !== undefined ? { durationMs } : {};
|
|
1039
|
+
// And how many times the body ran, when it did.
|
|
1040
|
+
const attempts =
|
|
1041
|
+
error instanceof MigronautError && typeof error.context?.attempts === 'number'
|
|
1042
|
+
? error.context.attempts
|
|
1043
|
+
: undefined;
|
|
1044
|
+
if (attempts !== undefined) span.set({ [ATTRIBUTES.MIGRATION_ATTEMPTS]: attempts });
|
|
1045
|
+
const retried = attempts > 1 ? { attempts } : {};
|
|
959
1046
|
// errorText, not the raw Error: a driver message can echo the
|
|
960
1047
|
// credentialed URI, and event subscribers (Sentry, JSON logs) would
|
|
961
1048
|
// ship it — the same redaction the log line below already gets.
|
|
@@ -964,6 +1051,7 @@ class MigratorKit extends EventEmitter {
|
|
|
964
1051
|
direction,
|
|
965
1052
|
...batchField,
|
|
966
1053
|
...durationField,
|
|
1054
|
+
...retried,
|
|
967
1055
|
error: errorText(error),
|
|
968
1056
|
});
|
|
969
1057
|
logger.error(
|
|
@@ -973,6 +1061,7 @@ class MigratorKit extends EventEmitter {
|
|
|
973
1061
|
direction,
|
|
974
1062
|
...batchField,
|
|
975
1063
|
...durationField,
|
|
1064
|
+
...retried,
|
|
976
1065
|
error: errorText(error),
|
|
977
1066
|
}),
|
|
978
1067
|
);
|
|
@@ -2280,7 +2369,7 @@ class MigratorKit extends EventEmitter {
|
|
|
2280
2369
|
#backgroundHost() {
|
|
2281
2370
|
return {
|
|
2282
2371
|
store: this.#backgroundStore(),
|
|
2283
|
-
deps: (owner) => this.#backgroundDeps(owner),
|
|
2372
|
+
deps: (owner, options) => this.#backgroundDeps(owner, options),
|
|
2284
2373
|
newId: () => this.#newId(),
|
|
2285
2374
|
logger: this.#logger,
|
|
2286
2375
|
emit: (event, payload) => this.#emit(event, payload),
|
|
@@ -2289,17 +2378,30 @@ class MigratorKit extends EventEmitter {
|
|
|
2289
2378
|
};
|
|
2290
2379
|
}
|
|
2291
2380
|
|
|
2292
|
-
/**
|
|
2293
|
-
|
|
2381
|
+
/**
|
|
2382
|
+
* What background.js runs with — `owner` names a lane: its lease's owner, its
|
|
2383
|
+
* events' runId. `job` is the queue job working the lane, when there is one
|
|
2384
|
+
* (`{ id }` for a background queue's lane, `{ id, groupId? }` for a run that
|
|
2385
|
+
* drives it inline): it goes on the lane's log lines and `migration:log`
|
|
2386
|
+
* events.
|
|
2387
|
+
*/
|
|
2388
|
+
#backgroundDeps(owner, { job } = {}) {
|
|
2294
2389
|
const config = this.#config;
|
|
2295
2390
|
const db = this.#requireDb();
|
|
2296
2391
|
const stamp = owner ? { runId: owner } : {};
|
|
2392
|
+
const lineStamp = job ? { ...stamp, ...jobFields(job) } : stamp;
|
|
2297
2393
|
return {
|
|
2298
2394
|
db,
|
|
2299
2395
|
client: this.#client,
|
|
2300
2396
|
store: this.#backgroundStore(),
|
|
2301
2397
|
logger: this.#logger,
|
|
2302
|
-
|
|
2398
|
+
// The lane's (or watcher's) run id, for ctx.background — `owner` itself
|
|
2399
|
+
// is a function here and a string in the watcher's deps.
|
|
2400
|
+
...(owner ? { runId: owner } : {}),
|
|
2401
|
+
// One counter per lane slice (or watcher): orders its migration:log events.
|
|
2402
|
+
logs: backgroundLogs({ sink: this.#logger, emitter: this.#logEmitter() }),
|
|
2403
|
+
...(job ? { job } : {}),
|
|
2404
|
+
fields: (extra) => ({ ...lineStamp, ...extra }),
|
|
2303
2405
|
emit: (event, payload) => this.#emit(event, { ...stamp, ...payload }),
|
|
2304
2406
|
lockFor: (name) =>
|
|
2305
2407
|
new MigrationLock(db, config.lockCollection, config.lockTTLSeconds, {
|
|
@@ -2575,10 +2677,14 @@ class MigratorKit extends EventEmitter {
|
|
|
2575
2677
|
this.#fields({ background: name, inline: true }),
|
|
2576
2678
|
);
|
|
2577
2679
|
await this.#backgroundReady(name, { lanes: true });
|
|
2680
|
+
// The lanes work for the run's queue job: their lines and events name it,
|
|
2681
|
+
// with its group — which a background queue's own lanes never have.
|
|
2682
|
+
const job = this.#runLog?.job;
|
|
2578
2683
|
const status = await drive(this.#backgroundHost(), name, {
|
|
2579
2684
|
signal,
|
|
2580
2685
|
concurrency: state.spec?.maxParallel ?? 1,
|
|
2581
2686
|
inline: true,
|
|
2687
|
+
...(job ? { job } : {}),
|
|
2582
2688
|
});
|
|
2583
2689
|
if (status.status === 'blocked') {
|
|
2584
2690
|
throw new BackgroundPendingError(
|
|
@@ -2637,13 +2743,20 @@ class MigratorKit extends EventEmitter {
|
|
|
2637
2743
|
/**
|
|
2638
2744
|
* One slice of one lane of a background migration: claim a partition and a
|
|
2639
2745
|
* slot, work it until `sliceMs` (the spec's by default) runs out, release.
|
|
2746
|
+
* `job: { id }` names the queue job working the lane, for its log lines.
|
|
2640
2747
|
* @experimental
|
|
2641
2748
|
*/
|
|
2642
|
-
async runBackgroundSlice(name, { signal, sliceMs } = {}) {
|
|
2749
|
+
async runBackgroundSlice(name, { signal, sliceMs, job } = {}) {
|
|
2643
2750
|
if (sliceMs !== undefined) assertSliceMs(sliceMs);
|
|
2751
|
+
// A lane's job has no group: `{ id }` only.
|
|
2752
|
+
assertJobValid(job, { groupId: false });
|
|
2644
2753
|
await this.#backgroundReady(name, { lanes: true });
|
|
2645
2754
|
const owner = this.#newId();
|
|
2646
|
-
return runSlice(this.#backgroundDeps(owner
|
|
2755
|
+
return runSlice(this.#backgroundDeps(owner, job ? { job } : {}), name, {
|
|
2756
|
+
signal,
|
|
2757
|
+
sliceMs,
|
|
2758
|
+
owner,
|
|
2759
|
+
});
|
|
2647
2760
|
}
|
|
2648
2761
|
|
|
2649
2762
|
/**
|
package/src/core/options.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { ConfigInvalidError, MigrationInvalidNameError } = require('../errors/index.js');
|
|
2
2
|
const { actorIssue } = require('../utils/actor.js');
|
|
3
|
+
const { isPlainObject } = require('../utils/canonical.js');
|
|
3
4
|
const { isCollectionName } = require('../utils/collection-name.js');
|
|
5
|
+
const { jobRefIssue } = require('../utils/job-ref.js');
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Validation of the options the kit's run methods take. Pure — no config, no
|
|
@@ -101,6 +103,20 @@ function assertActorValid(options) {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Validate `job`: the queue job a run works for, `{ id, groupId? }` — bound
|
|
108
|
+
* into the run's correlation, so it must be small and exactly that shape.
|
|
109
|
+
*/
|
|
110
|
+
function assertJobValid(job, options) {
|
|
111
|
+
const issue = jobRefIssue(job, options);
|
|
112
|
+
if (issue) {
|
|
113
|
+
// For the error's context: the keys of what was given, not its values.
|
|
114
|
+
throw new ConfigInvalidError(issue, {
|
|
115
|
+
job: isPlainObject(job) ? Object.keys(job).join(', ') : typeof job,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
/**
|
|
105
121
|
* Validate `checksum`: the SHA-256 the caller expects the named file to have
|
|
106
122
|
* — how a queue job says which version of the file it was planned with.
|
|
@@ -183,6 +199,7 @@ function assertUpOptions(filename, options) {
|
|
|
183
199
|
assertChecksumValid(options.checksum, filename);
|
|
184
200
|
assertBackgroundPendingValid(options.onBackgroundPending);
|
|
185
201
|
assertActorValid(options);
|
|
202
|
+
assertJobValid(options.job);
|
|
186
203
|
}
|
|
187
204
|
|
|
188
205
|
/** `down(filename, options)` */
|
|
@@ -193,12 +210,14 @@ function assertDownOptions(filename, options) {
|
|
|
193
210
|
assertToValid(options.to, filename, options);
|
|
194
211
|
assertOrderedValid(options.ordered, filename);
|
|
195
212
|
assertActorValid(options);
|
|
213
|
+
assertJobValid(options.job);
|
|
196
214
|
}
|
|
197
215
|
|
|
198
216
|
/** `redo(filename, options)` */
|
|
199
217
|
function assertRedoOptions(filename, options) {
|
|
200
218
|
assertFilename(filename);
|
|
201
219
|
assertActorValid(options);
|
|
220
|
+
assertJobValid(options.job);
|
|
202
221
|
}
|
|
203
222
|
|
|
204
223
|
/** `dryRun(direction, filename, options)` */
|
|
@@ -270,6 +289,7 @@ function assertImportOptions(options) {
|
|
|
270
289
|
|
|
271
290
|
module.exports = {
|
|
272
291
|
assertActorValid,
|
|
292
|
+
assertJobValid,
|
|
273
293
|
assertConvergeOptions,
|
|
274
294
|
assertDownOptions,
|
|
275
295
|
assertDryRunOptions,
|
package/src/core/run-recorder.js
CHANGED
|
@@ -17,6 +17,8 @@ const { ATTRIBUTES } = require('../utils/telemetry.js');
|
|
|
17
17
|
class RunRecorder {
|
|
18
18
|
#info;
|
|
19
19
|
#runId;
|
|
20
|
+
/** The queue job the run works for — `{ jobId?, groupId? }` — for its span */
|
|
21
|
+
#job;
|
|
20
22
|
#telemetry;
|
|
21
23
|
#emit;
|
|
22
24
|
#logger;
|
|
@@ -29,9 +31,10 @@ class RunRecorder {
|
|
|
29
31
|
/** Set once the lock is held and the run span is open */
|
|
30
32
|
#span;
|
|
31
33
|
|
|
32
|
-
constructor({ info, runId, telemetry, emit, logger, fields }) {
|
|
34
|
+
constructor({ info, runId, job = {}, telemetry, emit, logger, fields }) {
|
|
33
35
|
this.#info = info;
|
|
34
36
|
this.#runId = runId;
|
|
37
|
+
this.#job = job;
|
|
35
38
|
this.#telemetry = telemetry;
|
|
36
39
|
this.#emit = emit;
|
|
37
40
|
this.#logger = logger;
|
|
@@ -69,6 +72,8 @@ class RunRecorder {
|
|
|
69
72
|
[ATTRIBUTES.RUN_ID]: this.#runId,
|
|
70
73
|
[ATTRIBUTES.RUN_COMMAND]: this.#info.command,
|
|
71
74
|
[ATTRIBUTES.RUN_DIRECTION]: this.#info.direction,
|
|
75
|
+
[ATTRIBUTES.JOB_ID]: this.#job.jobId,
|
|
76
|
+
[ATTRIBUTES.JOB_GROUP_ID]: this.#job.groupId,
|
|
72
77
|
[ATTRIBUTES.LOCK_ACQUIRE_MS]: this.#acquired?.acquireMs,
|
|
73
78
|
[ATTRIBUTES.LOCK_SKIPPED]: this.#acquired?.skipped,
|
|
74
79
|
};
|
package/src/core/runner.js
CHANGED
|
@@ -84,9 +84,15 @@ async function withTimeout(promise, timeoutMs, name, direction, onTimeout) {
|
|
|
84
84
|
* body succeeded and only the (non-transactional) changelog write failed, the
|
|
85
85
|
* error carries `context.phase = 'changelog-write'` and `onError` is not fired —
|
|
86
86
|
* the migration itself did not fail.
|
|
87
|
+
*
|
|
88
|
+
* `attemptContext(attempt)` returns what the caller adds to the context of
|
|
89
|
+
* each attempt (`ctx.run`, `ctx.logger`): a retried transaction runs the body
|
|
90
|
+
* again, and that run gets a context of its own. Resolves to
|
|
91
|
+
* `{ duration, attempts }`; a failure carries `attempts` in its context.
|
|
87
92
|
*/
|
|
88
93
|
async function runMigration(params) {
|
|
89
94
|
const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
|
|
95
|
+
const { attemptContext } = params;
|
|
90
96
|
const fn = direction === 'up' ? migration.up : migration.down;
|
|
91
97
|
// A per-file `export const timeoutMs` overrides the global setting.
|
|
92
98
|
const timeoutMs = migration.timeoutMs ?? params.timeoutMs;
|
|
@@ -102,7 +108,18 @@ async function runMigration(params) {
|
|
|
102
108
|
const signal = context.signal
|
|
103
109
|
? AbortSignal.any([context.signal, timedOut.signal])
|
|
104
110
|
: timedOut.signal;
|
|
105
|
-
|
|
111
|
+
// A fresh context per attempt, not one mutated in place: a body from an
|
|
112
|
+
// earlier attempt that is still running (a timeout does not stop it) keeps
|
|
113
|
+
// the attempt it started with.
|
|
114
|
+
const contextOf = (session, attempt) => ({
|
|
115
|
+
...context,
|
|
116
|
+
signal,
|
|
117
|
+
...(session ? { session } : {}),
|
|
118
|
+
...attemptContext?.(attempt),
|
|
119
|
+
});
|
|
120
|
+
let attempts = 0;
|
|
121
|
+
// The context the body last ran with — what onError gets.
|
|
122
|
+
let runtimeContext;
|
|
106
123
|
const onTimeout = (timeoutError) => timedOut.abort(timeoutError);
|
|
107
124
|
let duration = 0;
|
|
108
125
|
// 'body' while the migration's own code runs; 'changelog' once it committed
|
|
@@ -114,25 +131,28 @@ async function runMigration(params) {
|
|
|
114
131
|
try {
|
|
115
132
|
if (useTransaction) {
|
|
116
133
|
session = context.client.startSession();
|
|
117
|
-
runtimeContext = { ...runtimeContext, session };
|
|
118
134
|
// withTransaction may run the body more than once when the driver retries
|
|
119
135
|
// a transient failure, so duration is re-measured on each attempt. The
|
|
120
136
|
// changelog write stays inside the transaction, so a failure there
|
|
121
137
|
// aborts the body's writes too — 'body' phase is accurate throughout.
|
|
122
138
|
await session.withTransaction(async () => {
|
|
123
139
|
const attemptStart = Date.now();
|
|
140
|
+
attempts += 1;
|
|
141
|
+
runtimeContext = contextOf(session, attempts);
|
|
124
142
|
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
125
143
|
duration = Date.now() - attemptStart;
|
|
126
144
|
await onSuccess?.(duration, session);
|
|
127
145
|
});
|
|
128
146
|
} else {
|
|
147
|
+
attempts += 1;
|
|
148
|
+
runtimeContext = contextOf(undefined, attempts);
|
|
129
149
|
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
130
150
|
duration = Date.now() - start;
|
|
131
151
|
phase = 'changelog';
|
|
132
152
|
await onSuccess?.(duration, undefined);
|
|
133
153
|
}
|
|
134
154
|
|
|
135
|
-
return { duration };
|
|
155
|
+
return { duration, attempts };
|
|
136
156
|
} catch (error) {
|
|
137
157
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
138
158
|
// Failures deserve timing data as much as successes — a slow-then-failing
|
|
@@ -162,15 +182,21 @@ async function runMigration(params) {
|
|
|
162
182
|
if (hooks?.onError) {
|
|
163
183
|
// A throwing onError hook must not replace the real cause.
|
|
164
184
|
try {
|
|
165
|
-
|
|
185
|
+
// A transaction that failed before its body ever ran (a session that
|
|
186
|
+
// would not start) still hands onError a whole context: a first
|
|
187
|
+
// attempt's, not counted as one.
|
|
188
|
+
await hooks.onError(name, err, runtimeContext ?? contextOf(session, 1));
|
|
166
189
|
} catch (hookError) {
|
|
167
190
|
const message = errorText(hookError);
|
|
168
191
|
logger?.warn(`⚠ onError hook failed for ${name}: ${message}`);
|
|
169
192
|
}
|
|
170
193
|
}
|
|
171
194
|
|
|
195
|
+
// How many times the body ran (the driver retries a transient
|
|
196
|
+
// transaction error by running it again) — absent if it never started.
|
|
197
|
+
const attemptsField = attempts > 0 ? { attempts } : {};
|
|
172
198
|
if (err instanceof MigrationTimeoutError) {
|
|
173
|
-
err.context = { durationMs: elapsed, ...err.context };
|
|
199
|
+
err.context = { durationMs: elapsed, ...attemptsField, ...err.context };
|
|
174
200
|
throw err;
|
|
175
201
|
}
|
|
176
202
|
// A standalone deployment refusing the transaction is a topology problem,
|
|
@@ -179,7 +205,7 @@ async function runMigration(params) {
|
|
|
179
205
|
throw new TransactionsUnsupportedError(
|
|
180
206
|
`Cannot run ${name} in a transaction — this deployment is standalone. ` +
|
|
181
207
|
'Set useTransaction: false, or run against a replica set / mongos.',
|
|
182
|
-
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
208
|
+
{ name, direction, durationMs: elapsed, ...attemptsField, cause: err.message },
|
|
183
209
|
{ cause: err },
|
|
184
210
|
);
|
|
185
211
|
}
|
|
@@ -187,7 +213,7 @@ async function runMigration(params) {
|
|
|
187
213
|
`Migration ${direction} failed: ${name}`,
|
|
188
214
|
// The message is duplicated into context because that is what survives
|
|
189
215
|
// JSON serialization; `cause` keeps the real Error (and its stack).
|
|
190
|
-
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
216
|
+
{ name, direction, durationMs: elapsed, ...attemptsField, cause: err.message },
|
|
191
217
|
{ cause: err },
|
|
192
218
|
);
|
|
193
219
|
} finally {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { isPlainObject } = require('./canonical.js');
|
|
2
|
+
const { MAX_ID_LENGTH } = require('./id.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The queue job a run works for: `job: { id, groupId? }` on `up`, `down` and
|
|
6
|
+
* `redo` (a background lane's slice takes `{ id }`). It is bound into
|
|
7
|
+
* `ctx.run`, the run's log lines and every `migration:log` event, so what a
|
|
8
|
+
* migration logs can be joined to the job a dashboard shows — nothing is
|
|
9
|
+
* stored. The one definition of its limits, shared by the kit's options and
|
|
10
|
+
* the queue adapter, which must never pass what the kit refuses.
|
|
11
|
+
*
|
|
12
|
+
* `id` allows a custom BullMQ job id (they can be long); `groupId` is minted
|
|
13
|
+
* by the kit's own id generator, so it has that generator's bound.
|
|
14
|
+
*/
|
|
15
|
+
const JOB_REF_LIMITS = Object.freeze({ id: 1024, groupId: MAX_ID_LENGTH });
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The problem with a job reference, or null when it is absent or valid.
|
|
19
|
+
* `groupId: false` refuses that key (a lane has no group).
|
|
20
|
+
*/
|
|
21
|
+
function jobRefIssue(job, { groupId = true } = {}) {
|
|
22
|
+
if (job === undefined) return null;
|
|
23
|
+
if (!isPlainObject(job)) return 'job must be an object: { id, groupId? }';
|
|
24
|
+
for (const key of Object.keys(job)) {
|
|
25
|
+
// Own keys only: `constructor` or `toString` must not pass for a limit.
|
|
26
|
+
if (!Object.hasOwn(JOB_REF_LIMITS, key) || (key === 'groupId' && !groupId)) {
|
|
27
|
+
return `job.${key} is not an option`;
|
|
28
|
+
}
|
|
29
|
+
const max = JOB_REF_LIMITS[key];
|
|
30
|
+
const value = job[key];
|
|
31
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max) {
|
|
32
|
+
return `job.${key} must be a non-empty string of at most ${max} characters`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return job.id === undefined ? 'job.id is required' : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a valid job reference adds to a run's correlation: `{ jobId, groupId? }` */
|
|
39
|
+
function jobFields(job) {
|
|
40
|
+
if (job === undefined) return {};
|
|
41
|
+
return { jobId: job.id, ...(job.groupId !== undefined ? { groupId: job.groupId } : {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { JOB_REF_LIMITS, jobFields, jobRefIssue };
|