@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.
@@ -4,9 +4,14 @@ const { errorText } = require('../utils/error.js');
4
4
  const { redactOutbound } = require('../utils/redact.js');
5
5
  const { JOB_NAMES, buildLaneJob, isObjectLike, parseBackgroundJobData } = require('./jobs.js');
6
6
  const {
7
+ DEFAULT_USERLAND_LOG_ROWS,
7
8
  UNRECOVERABLE_ERROR_NAME,
9
+ assertUserlandLogRows,
8
10
  isRetryableError,
11
+ jobRefOf,
9
12
  prepareErrorForQueue,
13
+ userlandOverflowRow,
14
+ userlandRow,
10
15
  } = require('./processor.js');
11
16
  const {
12
17
  DEFAULT_STALL_MS,
@@ -75,6 +80,7 @@ const PROCESSOR_KEYS = new Set([
75
80
  'pollIntervalMs',
76
81
  'stallMs',
77
82
  'maxLaneRetries',
83
+ 'userlandLogRows',
78
84
  ]);
79
85
 
80
86
  function resolveBackgroundProcessorOptions(options) {
@@ -96,6 +102,7 @@ function resolveBackgroundProcessorOptions(options) {
96
102
  pollIntervalMs = DEFAULTS.pollIntervalMs,
97
103
  stallMs = DEFAULT_STALL_MS,
98
104
  maxLaneRetries = DEFAULTS.maxLaneRetries,
105
+ userlandLogRows = DEFAULT_USERLAND_LOG_ROWS,
99
106
  } = options;
100
107
  if (kit !== undefined && config !== undefined) {
101
108
  throw new ConfigInvalidError('Pass either `kit` or `config`, not both');
@@ -132,7 +139,8 @@ function resolveBackgroundProcessorOptions(options) {
132
139
  });
133
140
  }
134
141
  assertBackgroundJobOptions(jobOptions);
135
- return { sliceMs, children, pollIntervalMs, stallMs, maxLaneRetries };
142
+ assertUserlandLogRows(userlandLogRows);
143
+ return { sliceMs, children, pollIntervalMs, stallMs, maxLaneRetries, userlandLogRows };
136
144
  }
137
145
 
138
146
  /**
@@ -147,6 +155,13 @@ function createBackgroundProcessor(options = {}) {
147
155
  const shutdownController = new AbortController();
148
156
  const inFlight = new Set();
149
157
  let warnedUnmovable = false;
158
+ /**
159
+ * The lanes working a slice in this process, by job id — lanes run side by
160
+ * side, so a `migration:log` event's `jobId` is what says whose log it goes
161
+ * to. `writes` are its rows still in flight, drained when the slice ends;
162
+ * `rows`/`dropped` count them against `userlandLogRows`, per slice.
163
+ */
164
+ const lanes = new Map();
150
165
 
151
166
  /** A log row on the job — never allowed to fail it */
152
167
  async function log(job, row) {
@@ -190,6 +205,59 @@ function createBackgroundProcessor(options = {}) {
190
205
  throw moved(DELAYED_ERROR_NAME);
191
206
  }
192
207
 
208
+ /** A row on a lane's job, tracked so the slice's end can wait for it */
209
+ function laneLog(lane, row) {
210
+ const pending = log(lane.job, row);
211
+ lane.writes.add(pending);
212
+ pending.finally(() => lane.writes.delete(pending));
213
+ }
214
+
215
+ /**
216
+ * A lane's userland lines, into its own job's log — never allowed to fail
217
+ * it. A line with a group is a lane a migration job drives inline: its job
218
+ * is on the other queue, whatever its id.
219
+ */
220
+ function onUserland(event) {
221
+ if (event?.kind !== 'background' || event.jobId === undefined) return;
222
+ if (event.groupId !== undefined) return;
223
+ const lane = lanes.get(event.jobId);
224
+ if (lane === undefined) return;
225
+ if (lane.rows < settings.userlandLogRows) {
226
+ lane.rows += 1;
227
+ laneLog(lane, userlandRow(event));
228
+ } else {
229
+ lane.dropped += 1;
230
+ }
231
+ }
232
+ if (typeof kit.on === 'function') kit.on('migration:log', onUserland);
233
+
234
+ /**
235
+ * Run one slice as `job`'s: the slice gets `{ id }` for its lines and
236
+ * events, and its userland lines reach the job's log until the slice ends —
237
+ * drained before the job moves on, so none lands after it.
238
+ */
239
+ async function asLane(job, fn) {
240
+ const ref = jobRefOf(job);
241
+ if (ref === undefined) {
242
+ kit.logger.debug(
243
+ `Lane job ${job?.id} has no id a slice can carry — its lines name no job`,
244
+ {},
245
+ );
246
+ return fn(undefined);
247
+ }
248
+ const lane = { job, writes: new Set(), rows: 0, dropped: 0 };
249
+ lanes.set(ref.id, lane);
250
+ try {
251
+ return await fn(ref);
252
+ } finally {
253
+ if (lanes.get(ref.id) === lane) lanes.delete(ref.id);
254
+ if (lane.dropped > 0) {
255
+ laneLog(lane, userlandOverflowRow(lane.dropped, settings.userlandLogRows));
256
+ }
257
+ await Promise.allSettled([...lane.writes]);
258
+ }
259
+ }
260
+
193
261
  /** Heal from MongoDB: a coordinator for every background migration with work to do */
194
262
  async function heal(reason) {
195
263
  try {
@@ -327,10 +395,13 @@ function createBackgroundProcessor(options = {}) {
327
395
  if (shutdownController.signal.aborted) return later(ctx, 0, { ...base, outcome: 'stopped' });
328
396
  let slice;
329
397
  try {
330
- slice = await kit.runBackgroundSlice(name, {
331
- signal: ctx.abort,
332
- ...(settings.sliceMs !== undefined ? { sliceMs: settings.sliceMs } : {}),
333
- });
398
+ slice = await asLane(job, (ref) =>
399
+ kit.runBackgroundSlice(name, {
400
+ signal: ctx.abort,
401
+ ...(settings.sliceMs !== undefined ? { sliceMs: settings.sliceMs } : {}),
402
+ ...(ref ? { job: ref } : {}),
403
+ }),
404
+ );
334
405
  } catch (error) {
335
406
  if (shutdownController.signal.aborted) {
336
407
  return later(ctx, 0, { ...base, outcome: 'stopped' });
@@ -451,6 +522,7 @@ function createBackgroundProcessor(options = {}) {
451
522
  processor.close = async () => {
452
523
  processor.shutdown();
453
524
  await Promise.allSettled([...inFlight]);
525
+ if (typeof kit.off === 'function') kit.off('migration:log', onUserland);
454
526
  if (ownsKit) await kit.disconnect();
455
527
  };
456
528
 
@@ -9,8 +9,11 @@ const {
9
9
  RunAbortedError,
10
10
  } = require('../errors/index.js');
11
11
  const { pickActor } = require('../utils/actor.js');
12
+ const { isPlainObject } = require('../utils/canonical.js');
12
13
  const { errorText } = require('../utils/error.js');
14
+ const { jobRefIssue } = require('../utils/job-ref.js');
13
15
  const { redactDeep, redactOutbound } = require('../utils/redact.js');
16
+ const { sanitize } = require('../utils/sanitize.js');
14
17
  const { JOB_NAMES, assertAllowed, isObjectLike, parseJobData, resolveAllow } = require('./jobs.js');
15
18
  const {
16
19
  assertBackgroundJobOptions,
@@ -79,14 +82,162 @@ function jobIds(ctx) {
79
82
  };
80
83
  }
81
84
 
85
+ /**
86
+ * The job a run works for, as the kit's `job` option — `{ id, groupId? }` (a
87
+ * lane's slice: `{ id }`), or undefined when the job has no id the kit would
88
+ * take (it then logs nothing of the job; the run itself is unaffected). A
89
+ * group the kit would refuse is left out rather than the whole reference.
90
+ */
91
+ function jobRefOf(job, groupId) {
92
+ if (job?.id === undefined || job.id === null) return undefined;
93
+ const id = String(job.id);
94
+ const ref = groupId !== undefined ? { id, groupId } : { id };
95
+ if (jobRefIssue(ref) === null) return ref;
96
+ return jobRefIssue({ id }) === null ? { id } : undefined;
97
+ }
98
+
99
+ /** The longest row a `migration:log` event becomes in a job's log */
100
+ const USERLAND_ROW_MAX = 1024;
101
+
102
+ /**
103
+ * How many `migration:log` rows one job's log takes by default (a lane: one
104
+ * slice) — the rest are counted in one closing row. A migration that logs in
105
+ * a loop must not write tens of thousands of entries into Redis per job.
106
+ */
107
+ const DEFAULT_USERLAND_LOG_ROWS = 1000;
108
+
109
+ /** Line breaks: a row is one line of the job's log, so they are shown, never acted on */
110
+ const ROW_BREAKS = /\r\n|[\r\n\u2028\u2029]/g;
111
+
112
+ /** Text as part of one row: line breaks shown as ⏎, control characters dropped */
113
+ const oneLine = (text) => sanitize(String(text).replace(ROW_BREAKS, '⏎'));
114
+
115
+ /** BigInts have no JSON form of their own; a log row shows their digits */
116
+ const jsonValue = (_key, value) => (typeof value === 'bigint' ? value.toString() : value);
117
+
118
+ /** Stops {@link boundedJson} once the row has what it can show */
119
+ const ROW_FULL = Symbol('row full');
120
+
121
+ /**
122
+ * `data` as JSON, written only until `max` characters — never the whole of
123
+ * it: an event's data may hold a thousand entries of 4 KB each, and the row
124
+ * shows a kilobyte. The JSON of what `JSON.stringify` would write, cut short.
125
+ */
126
+ function boundedJson(data, max) {
127
+ let out = '';
128
+ const push = (text) => {
129
+ if (out.length + text.length > max) {
130
+ out += text.slice(0, max - out.length + 1);
131
+ throw ROW_FULL;
132
+ }
133
+ out += text;
134
+ };
135
+ const write = (value) => {
136
+ if (typeof value === 'bigint') return push(`"${value}"`);
137
+ // Arrays and plain objects are walked here; anything else is a leaf.
138
+ if (!Array.isArray(value) && !isPlainObject(value)) {
139
+ return push(JSON.stringify(value, jsonValue) ?? 'null');
140
+ }
141
+ if (Array.isArray(value)) {
142
+ push('[');
143
+ for (let index = 0; index < value.length; index++) {
144
+ if (index > 0) push(',');
145
+ write(value[index]);
146
+ }
147
+ return push(']');
148
+ }
149
+ push('{');
150
+ let first = true;
151
+ for (const key of Object.keys(value)) {
152
+ const item = value[key];
153
+ if (item === undefined || typeof item === 'function' || typeof item === 'symbol') continue;
154
+ push(`${first ? '' : ','}${JSON.stringify(key)}:`);
155
+ first = false;
156
+ write(item);
157
+ }
158
+ return push('}');
159
+ };
160
+ try {
161
+ write(data);
162
+ } catch (error) {
163
+ if (error !== ROW_FULL) throw error;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Whether `data` has a key to show — without listing them all */
169
+ function hasEntries(data) {
170
+ if (data === null || typeof data !== 'object') return false;
171
+ for (const _key in data) return true;
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * A `migration:log` event as a row of the job's log, next to the processor's
177
+ * own lifecycle rows: `✎ <level: ><msg> <data as JSON>`, the attempt when a
178
+ * transaction was retried, the partition of a background lane. One line —
179
+ * a line break in the message cannot forge a row of its own — cut at
180
+ * {@link USERLAND_ROW_MAX}; redacted on its way out like every row.
181
+ */
182
+ function userlandRow(event) {
183
+ const level = event.level === 'info' ? '' : `${event.level}: `;
184
+ const attempt = event.attempt > 1 ? ` (attempt ${event.attempt})` : '';
185
+ const partition =
186
+ event.kind === 'background' && event.partition ? ` [partition ${event.partition}]` : '';
187
+ const head = `✎ ${level}${event.msg}`;
188
+ let data = '';
189
+ if (hasEntries(event.data) && head.length < USERLAND_ROW_MAX) {
190
+ try {
191
+ data = ` ${boundedJson(event.data, USERLAND_ROW_MAX - head.length)}`;
192
+ } catch {
193
+ data = ' [data not serializable]';
194
+ }
195
+ }
196
+ const row = oneLine(`${head}${data}${attempt}${partition}`);
197
+ return row.length > USERLAND_ROW_MAX ? `${row.slice(0, USERLAND_ROW_MAX - 1)}…` : row;
198
+ }
199
+
200
+ /** The closing row of a job whose userland lines went past the limit */
201
+ function userlandOverflowRow(dropped, limit) {
202
+ return `✎ … ${dropped} more line(s) past the limit of ${limit} not mirrored here — see migration:log`;
203
+ }
204
+
205
+ /** `userlandLogRows`: how many userland rows one job's log takes */
206
+ function assertUserlandLogRows(value) {
207
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
208
+ throw new ConfigInvalidError('userlandLogRows must be an integer of at least 0', {
209
+ userlandLogRows: value,
210
+ });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Whether a `migration:log` event is the job's own. Matched by run and job,
216
+ * not by the job in flight alone: a body that outlived its timeout may still
217
+ * log while the next job runs — the same job again, if it was put back in the
218
+ * queue — and its lines must not land in that run's log. A background
219
+ * migration the run drives inline logs from lanes of its own (their own run
220
+ * ids), named by the job and its group: a background queue's lanes have no
221
+ * group, and their ids may well repeat a migration job's.
222
+ */
223
+ function ownLine(ctx, event) {
224
+ const ref = ctx.jobRef;
225
+ if (event.kind === 'background') {
226
+ return ref?.groupId !== undefined && event.jobId === ref.id && event.groupId === ref.groupId;
227
+ }
228
+ if (event.kind !== 'migration') return false;
229
+ if (ctx.runId === undefined || event.runId !== ctx.runId) return false;
230
+ return ref === undefined || event.jobId === ref.id;
231
+ }
232
+
82
233
  /** A job in a few words, for log lines: `up 20260101-x.js (1/3)`, `converge`, `sync` */
83
234
  function describeJob(data) {
84
235
  if (data.kind !== 'migration') return data.kind;
85
236
  return `${data.direction} ${data.migration} (${data.index + 1}/${data.total})`;
86
237
  }
87
238
 
88
- /** The structured fields of a job's log lines */
89
- function jobFields(data) {
239
+ /** The structured fields of a job's log lines: what the job is about */
240
+ function jobSummary(data) {
90
241
  if (data.kind !== 'migration') return { kind: data.kind };
91
242
  return {
92
243
  migration: data.migration,
@@ -139,7 +290,16 @@ function resolveProcessorOptions(options) {
139
290
  if (!isObjectLike(options)) {
140
291
  throw new ConfigInvalidError('createMigrationProcessor options must be an object');
141
292
  }
142
- const { kit, config, lockWait = {}, jobOptions, ordered = true, allow, background } = options;
293
+ const {
294
+ kit,
295
+ config,
296
+ lockWait = {},
297
+ jobOptions,
298
+ ordered = true,
299
+ allow,
300
+ background,
301
+ userlandLogRows = DEFAULT_USERLAND_LOG_ROWS,
302
+ } = options;
143
303
  if (kit !== undefined && config !== undefined) {
144
304
  throw new ConfigInvalidError('Pass either `kit` or `config`, not both');
145
305
  }
@@ -158,7 +318,8 @@ function resolveProcessorOptions(options) {
158
318
  assertLockWaitOptions(waitOptions);
159
319
  assertJobOptions(jobOptions);
160
320
  if (background !== undefined) assertBackgroundLink(background);
161
- return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow) };
321
+ assertUserlandLogRows(userlandLogRows);
322
+ return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow), userlandLogRows };
162
323
  }
163
324
 
164
325
  /**
@@ -189,7 +350,7 @@ function assertBackgroundLink(background) {
189
350
  * signal only to processors whose `length` is at least 3.
190
351
  */
191
352
  function createMigrationProcessor(options = {}) {
192
- const { waitOptions, defaultOrdered, allow } = resolveProcessorOptions(options);
353
+ const { waitOptions, defaultOrdered, allow, userlandLogRows } = resolveProcessorOptions(options);
193
354
  const { kit: injectedKit, config, kitOptions, queue, jobOptions, background } = options;
194
355
 
195
356
  const ownsKit = injectedKit === undefined;
@@ -235,6 +396,29 @@ function createMigrationProcessor(options = {}) {
235
396
  );
236
397
  const flush = (ctx) => Promise.allSettled([...ctx.writes]);
237
398
 
399
+ /** A userland line into the job's log — up to `userlandLogRows`, then only counted */
400
+ const mirror = (ctx, event) => {
401
+ if (ctx.userlandRows < userlandLogRows) {
402
+ ctx.userlandRows += 1;
403
+ log(ctx, userlandRow(event));
404
+ } else {
405
+ ctx.userlandDropped += 1;
406
+ }
407
+ };
408
+
409
+ /**
410
+ * Nothing more is written for the migration once the job settles: a row
411
+ * after BullMQ removed the job would leave its log behind in Redis. What
412
+ * went past the limit is said in one last row first.
413
+ */
414
+ const seal = (ctx) => {
415
+ if (ctx.sealed) return;
416
+ if (ctx.userlandDropped > 0) {
417
+ log(ctx, userlandOverflowRow(ctx.userlandDropped, userlandLogRows));
418
+ }
419
+ ctx.sealed = true;
420
+ };
421
+
238
422
  // Subscribed once, for the processor's lifetime: the kit emits per run, and
239
423
  // `current` says which job that run belongs to.
240
424
  const listeners = {
@@ -305,6 +489,10 @@ function createMigrationProcessor(options = {}) {
305
489
  'converge:end': (event) => {
306
490
  if (current && event.success) log(current, `✔ Converged ${event.changed} change(s)`);
307
491
  },
492
+ // What the migration itself logged for its users — see ownLine.
493
+ 'migration:log': (event) => {
494
+ if (current && !current.sealed && ownLine(current, event)) mirror(current, event);
495
+ },
308
496
  };
309
497
  for (const [event, listener] of Object.entries(listeners)) kit.on(event, listener);
310
498
 
@@ -373,6 +561,15 @@ function createMigrationProcessor(options = {}) {
373
561
  async function runMigrationJob(ctx, signal) {
374
562
  const { data } = ctx;
375
563
  const ordered = data.ordered ?? defaultOrdered;
564
+ // The run's correlation names this job, so what the migration logs can be
565
+ // joined to it (and its userland lines routed into its log).
566
+ ctx.jobRef = jobRefOf(ctx.job, data.groupId);
567
+ if (ctx.jobRef === undefined) {
568
+ kit.logger.debug(`Job ${ctx.job?.id} has no id the run can carry — its lines name no job`, {
569
+ ...jobIds(ctx),
570
+ });
571
+ }
572
+ const jobField = ctx.jobRef ? { job: ctx.jobRef } : {};
376
573
  const attempt = () =>
377
574
  data.direction === JOB_NAMES.UP
378
575
  ? kit.up(data.migration, {
@@ -381,8 +578,13 @@ function createMigrationProcessor(options = {}) {
381
578
  ...(data.force ? { force: true } : {}),
382
579
  ...(data.checksum ? { checksum: data.checksum } : {}),
383
580
  ...pickActor(data),
581
+ ...jobField,
384
582
  })
385
- : kit.down(data.migration, { ...(ordered ? { ordered: true } : {}), ...pickActor(data) });
583
+ : kit.down(data.migration, {
584
+ ...(ordered ? { ordered: true } : {}),
585
+ ...pickActor(data),
586
+ ...jobField,
587
+ });
386
588
 
387
589
  try {
388
590
  const { result, waitedMs } = await waitForLock(ctx, attempt, signal);
@@ -636,6 +838,9 @@ function createMigrationProcessor(options = {}) {
636
838
  started: false,
637
839
  writes: new Set(),
638
840
  registered: [],
841
+ userlandRows: 0,
842
+ userlandDropped: 0,
843
+ sealed: false,
639
844
  };
640
845
  const startedAt = Date.now();
641
846
  const signals = [shutdownController.signal];
@@ -658,22 +863,24 @@ function createMigrationProcessor(options = {}) {
658
863
  abort.addEventListener('abort', onAbort, { once: true });
659
864
  kit.logger.debug(`▶ Job ${job?.id} (${describeJob(ctx.data)})`, {
660
865
  ...jobIds(ctx),
661
- ...jobFields(ctx.data),
866
+ ...jobSummary(ctx.data),
662
867
  });
663
868
  await kit.connect();
664
869
  let result;
665
870
  if (ctx.data.kind === 'sync') result = await runSyncJob(ctx);
666
871
  else if (ctx.data.kind === 'converge') result = await runConvergeJob(ctx, abort);
667
872
  else result = await runMigrationJob(ctx, abort);
873
+ seal(ctx);
668
874
  progress(ctx, 'completed', ctx.runId ? { runId: ctx.runId } : {});
669
875
  await flush(ctx);
670
876
  kit.logger.debug(`✔ Job ${job?.id} done`, {
671
877
  ...jobIds(ctx),
672
- ...jobFields(ctx.data),
878
+ ...jobSummary(ctx.data),
673
879
  durationMs: Date.now() - startedAt,
674
880
  });
675
881
  return result;
676
882
  } catch (error) {
883
+ seal(ctx);
677
884
  const requeued = await requeueOnShutdown(ctx, error, token);
678
885
  if (requeued) throw requeued;
679
886
  // BullMQ only retries when the job was given more than one attempt — the
@@ -738,9 +945,15 @@ module.exports = {
738
945
  RETRYABLE_CODES,
739
946
  UNRECOVERABLE_ERROR_NAME,
740
947
  WAITING_ERROR_NAME,
948
+ DEFAULT_USERLAND_LOG_ROWS,
949
+ assertUserlandLogRows,
741
950
  createMigrationProcessor,
742
951
  isTransientForJob,
743
952
  isRetryableError,
953
+ jobRefOf,
954
+ ownLine,
744
955
  prepareErrorForQueue,
745
956
  resolveProcessorOptions,
957
+ userlandOverflowRow,
958
+ userlandRow,
746
959
  };
@@ -222,6 +222,7 @@ class MigrationQueue {
222
222
  lockWait,
223
223
  allow,
224
224
  background,
225
+ userlandLogRows,
225
226
  } = options;
226
227
 
227
228
  if (!isObjectLike(bullmq)) {
@@ -307,6 +308,7 @@ class MigrationQueue {
307
308
  ...(config !== undefined ? { config } : {}),
308
309
  ...(lockWait !== undefined ? { lockWait } : {}),
309
310
  ...(allow !== undefined ? { allow } : {}),
311
+ ...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
310
312
  });
311
313
  this.#allow = resolveAllow(allow);
312
314
  const backgroundSettings = resolveBackground(background, {
@@ -367,6 +369,7 @@ class MigrationQueue {
367
369
  kit: this.#kit,
368
370
  queue: this.#backgroundQueue,
369
371
  ...MigrationQueue.#backgroundProcessorOptions(backgroundSettings),
372
+ ...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
370
373
  });
371
374
  }
372
375
  this.#processor = createMigrationProcessor({
@@ -377,6 +380,7 @@ class MigrationQueue {
377
380
  ...(lockWait !== undefined ? { lockWait } : {}),
378
381
  ...(jobOptions !== undefined ? { jobOptions } : {}),
379
382
  ...(allow !== undefined ? { allow } : {}),
383
+ ...(userlandLogRows !== undefined ? { userlandLogRows } : {}),
380
384
  // What an `up` registers starts on the background queue at once.
381
385
  ...(this.#backgroundQueue !== undefined
382
386
  ? {
@@ -13,6 +13,7 @@ const {
13
13
  const { idRangePartitioner } = require('./background-partition.js');
14
14
  const { runSandbox } = require('./background-sandbox.js');
15
15
  const { toRelaxedEjson } = require('./bson-peer.js');
16
+ const { backgroundLogs } = require('./migration-logger.js');
16
17
  const { READ_OPTIONS } = require('./server-info.js');
17
18
 
18
19
  /**
@@ -52,6 +53,12 @@ function sampleSize({ sample, first }) {
52
53
  return n;
53
54
  }
54
55
 
56
+ /**
57
+ * `ctx.logger` in a dry run: the lines say `dryRun: true`, and nothing is
58
+ * emitted — a preview's logs are not the application's to keep.
59
+ */
60
+ const dryLogs = (logger) => backgroundLogs({ sink: logger, dryRun: true });
61
+
55
62
  /** The job a dry run works with — no partition, no lease */
56
63
  function dryJob(name, loaded, direction, logger) {
57
64
  return {
@@ -64,6 +71,7 @@ function dryJob(name, loaded, direction, logger) {
64
71
  partitionId: 'dry-run',
65
72
  match: matchOf(loaded.spec, direction),
66
73
  logger,
74
+ logs: dryLogs(logger),
67
75
  };
68
76
  }
69
77
 
@@ -301,6 +309,7 @@ async function previewSteps(deps, name, loaded, options = {}) {
301
309
  generation: 0,
302
310
  partitionId: 'dry-run',
303
311
  logger: deps.logger,
312
+ logs: dryLogs(deps.logger),
304
313
  };
305
314
  const log = [];
306
315
  let stoppedBy = 'steps';
@@ -70,17 +70,38 @@ function excludeBadIds(match, badIds = []) {
70
70
  return badIds.length > 0 ? { $and: [match, { _id: { $nin: badIds } }] } : match;
71
71
  }
72
72
 
73
- /** What a transformation sees besides the document */
74
- function transformContext(job, extra = {}) {
73
+ /**
74
+ * `ctx.background`: which background migration, generation and partition —
75
+ * and, for `ctx.logger` and `migration:log`, the lane (`runId`, its owner),
76
+ * the queue job working it (and its group, when a run drives it inline) and
77
+ * the transaction attempt. Frozen, like an ordinary migration's `ctx.run`.
78
+ */
79
+ function backgroundInfo(job, attempt) {
80
+ return Object.freeze({
81
+ name: job.name,
82
+ generation: job.generation,
83
+ partition: String(job.partitionId ?? ''),
84
+ ...(job.runId !== undefined ? { runId: job.runId } : {}),
85
+ ...(job.jobId !== undefined ? { jobId: job.jobId } : {}),
86
+ ...(job.groupId !== undefined ? { groupId: job.groupId } : {}),
87
+ attempt,
88
+ });
89
+ }
90
+
91
+ /**
92
+ * What a transformation sees besides the document. `attempt` counts the
93
+ * transactions a transactional batch or step went through — each one runs the
94
+ * user's code again — and is 1 everywhere else. `job.logs` binds a logger to
95
+ * `ctx.background`; a job without it (a test's) gets the plain logger.
96
+ */
97
+ function transformContext(job, extra = {}, attempt = 1) {
98
+ const direction = job.direction ?? 'forward';
99
+ const background = backgroundInfo(job, attempt);
75
100
  return {
76
101
  signal: job.signal,
77
- logger: job.logger,
78
- direction: job.direction ?? 'forward',
79
- background: {
80
- name: job.name,
81
- generation: job.generation,
82
- partition: String(job.partitionId ?? ''),
83
- },
102
+ logger: job.logs ? job.logs(background, direction) : job.logger,
103
+ direction,
104
+ background,
84
105
  ...extra,
85
106
  };
86
107
  }
@@ -228,14 +249,14 @@ function byIds(docs, partitioner) {
228
249
  async function applyBatch(
229
250
  job,
230
251
  docs,
231
- { db, session, ctxExtra, abortOnConflict = false, strict = false, bare = false } = {},
252
+ { db, session, ctxExtra, abortOnConflict = false, strict = false, bare = false, attempt } = {},
232
253
  ) {
233
254
  const spec = job.spec;
234
255
  const collection = db.collection(spec.collection);
235
256
  const { source, target } = directionOf(spec, job.fns, job.direction);
236
257
  // `left`: documents read and not rewritten — a draining partition steps over them.
237
258
  const counts = { migrated: 0, skipped: 0, conflicts: 0, retried: 0, errors: [], left: [] };
238
- const ctx = transformContext(job, ctxExtra);
259
+ const ctx = transformContext(job, ctxExtra, attempt);
239
260
  let pending = docs;
240
261
  for (let round = 0; pending.length > 0; round++) {
241
262
  const transformed = await transformAll(job, pending, ctx);
@@ -338,9 +359,12 @@ function documentError(entry) {
338
359
  * (and, in a transaction or a dry run, the session), its last checkpoint,
339
360
  * and the deadline it should return by.
340
361
  */
341
- function buildStepContext(job, { db, client, session, checkpoint, deadline, dryRun } = {}) {
362
+ function buildStepContext(
363
+ job,
364
+ { db, client, session, checkpoint, deadline, dryRun, attempt } = {},
365
+ ) {
342
366
  return {
343
- ...transformContext(job),
367
+ ...transformContext(job, {}, attempt),
344
368
  db,
345
369
  client,
346
370
  checkpoint: checkpoint ?? null,
@@ -427,20 +451,22 @@ async function runStep(job, ctx, cursor) {
427
451
  );
428
452
  return { next, counts };
429
453
  };
430
- const stepContext = (session) =>
454
+ const stepContext = (session, attempt) =>
431
455
  buildStepContext(job, {
432
456
  db,
433
457
  client,
434
458
  checkpoint: cursor.checkpoint,
435
459
  deadline: ctx.deadline,
460
+ attempt,
436
461
  ...(session ? { session } : {}),
437
462
  });
438
- if (!job.spec.transaction) return save(readStepResult(await fn(stepContext())));
463
+ if (!job.spec.transaction) return save(readStepResult(await fn(stepContext(undefined, 1))));
439
464
  for (let attempt = 0; ; attempt++) {
440
465
  const session = client.startSession();
441
466
  try {
442
467
  session.startTransaction(transactionOptions(job.spec));
443
- const saved = await save(readStepResult(await fn(stepContext(session))), session);
468
+ const result = await fn(stepContext(session, attempt + 1));
469
+ const saved = await save(readStepResult(result), session);
444
470
  await commit(session);
445
471
  lease.touch();
446
472
  return saved;
@@ -602,9 +628,13 @@ async function transactionalBatch(job, ctx, cursor, batchSize) {
602
628
  let size = Math.min(ctx.txn.size, batchSize);
603
629
  let attempts = 0;
604
630
  let txnRetries = 0;
631
+ // Every turn of the loop is a transaction of its own that runs the
632
+ // transformations again: what they log says which one it was.
633
+ let transactions = 0;
605
634
  const excluded = new Map();
606
635
  for (;;) {
607
636
  if (signal?.aborted) throw signal.reason;
637
+ transactions += 1;
608
638
  const session = client.startSession();
609
639
  let docs = [];
610
640
  try {
@@ -628,6 +658,7 @@ async function transactionalBatch(job, ctx, cursor, batchSize) {
628
658
  ctxExtra: { session, db, client },
629
659
  abortOnConflict: true,
630
660
  strict: true,
661
+ attempt: transactions,
631
662
  })
632
663
  : { migrated: 0, skipped: 0, conflicts: 0, retried: 0, errors: [], left: [] };
633
664
  const errors = [...excluded.values()];