@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
|
@@ -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
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
package/src/bullmq/processor.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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, {
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
};
|
package/src/bullmq/service.js
CHANGED
|
@@ -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
|
-
/**
|
|
74
|
-
|
|
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
|
|
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(
|
|
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
|
|
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()];
|