@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.
@@ -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
- super();
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, { ...(this.#runId ? { runId: this.#runId } : {}), ...payload });
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', [context]);
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
- context,
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({ migration: name, direction, ...batchField, durationMs: duration }),
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
- /** What background.js runs with — `owner` names a lane: its lease's owner, its events' runId */
2293
- #backgroundDeps(owner) {
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
- fields: (extra) => ({ ...stamp, ...extra }),
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), name, { signal, sliceMs, owner });
2755
+ return runSlice(this.#backgroundDeps(owner, job ? { job } : {}), name, {
2756
+ signal,
2757
+ sliceMs,
2758
+ owner,
2759
+ });
2647
2760
  }
2648
2761
 
2649
2762
  /**
@@ -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,
@@ -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
  };
@@ -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
- let runtimeContext = { ...context, signal };
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
- await hooks.onError(name, err, runtimeContext);
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 };