@alexify/migronaut 2.2.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 +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/src/bullmq/processor.js
CHANGED
|
@@ -9,16 +9,19 @@ 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');
|
|
17
|
+
const { JOB_NAMES, assertAllowed, isObjectLike, parseJobData, resolveAllow } = require('./jobs.js');
|
|
14
18
|
const {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} = require('./
|
|
21
|
-
const { assertJobOptions, enqueueConverge, enqueueUp } = require('./producer.js');
|
|
19
|
+
assertBackgroundJobOptions,
|
|
20
|
+
assertJobOptions,
|
|
21
|
+
enqueueBackground,
|
|
22
|
+
enqueueConverge,
|
|
23
|
+
enqueueUp,
|
|
24
|
+
} = require('./producer.js');
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Failures a later attempt can get past without anything being fixed: the lock
|
|
@@ -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,
|
|
@@ -136,10 +287,19 @@ function prepareErrorForQueue(error) {
|
|
|
136
287
|
* opens any connection of its own.
|
|
137
288
|
*/
|
|
138
289
|
function resolveProcessorOptions(options) {
|
|
139
|
-
if (!
|
|
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
|
}
|
|
@@ -149,7 +309,7 @@ function resolveProcessorOptions(options) {
|
|
|
149
309
|
if (typeof ordered !== 'boolean') {
|
|
150
310
|
throw new ConfigInvalidError('ordered must be a boolean', { ordered });
|
|
151
311
|
}
|
|
152
|
-
if (!
|
|
312
|
+
if (!isObjectLike(lockWait)) {
|
|
153
313
|
throw new ConfigInvalidError('lockWait must be an object', { lockWait: typeof lockWait });
|
|
154
314
|
}
|
|
155
315
|
// Unlike runMigrations, waiting is the default: nothing is blocked on this
|
|
@@ -157,7 +317,25 @@ function resolveProcessorOptions(options) {
|
|
|
157
317
|
const waitOptions = { onLockHeld: 'wait', ...lockWait };
|
|
158
318
|
assertLockWaitOptions(waitOptions);
|
|
159
319
|
assertJobOptions(jobOptions);
|
|
160
|
-
|
|
320
|
+
if (background !== undefined) assertBackgroundLink(background);
|
|
321
|
+
assertUserlandLogRows(userlandLogRows);
|
|
322
|
+
return { waitOptions, defaultOrdered: ordered, allow: resolveAllow(allow), userlandLogRows };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The background queue a migration processor hands what it registers to:
|
|
327
|
+
* `{ queue, jobOptions?, stallMs? }`.
|
|
328
|
+
*/
|
|
329
|
+
function assertBackgroundLink(background) {
|
|
330
|
+
if (!isObjectLike(background) || typeof background.queue?.addBulk !== 'function') {
|
|
331
|
+
throw new ConfigInvalidError('background must be { queue } — the background queue');
|
|
332
|
+
}
|
|
333
|
+
for (const key of Object.keys(background)) {
|
|
334
|
+
if (key !== 'queue' && key !== 'jobOptions' && key !== 'stallMs') {
|
|
335
|
+
throw new ConfigInvalidError(`background.${key} is not an option here`, { key });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
assertBackgroundJobOptions(background.jobOptions);
|
|
161
339
|
}
|
|
162
340
|
|
|
163
341
|
/**
|
|
@@ -172,8 +350,8 @@ function resolveProcessorOptions(options) {
|
|
|
172
350
|
* signal only to processors whose `length` is at least 3.
|
|
173
351
|
*/
|
|
174
352
|
function createMigrationProcessor(options = {}) {
|
|
175
|
-
const { waitOptions, defaultOrdered, allow } = resolveProcessorOptions(options);
|
|
176
|
-
const { kit: injectedKit, config, kitOptions, queue, jobOptions } = options;
|
|
353
|
+
const { waitOptions, defaultOrdered, allow, userlandLogRows } = resolveProcessorOptions(options);
|
|
354
|
+
const { kit: injectedKit, config, kitOptions, queue, jobOptions, background } = options;
|
|
177
355
|
|
|
178
356
|
const ownsKit = injectedKit === undefined;
|
|
179
357
|
const kit = injectedKit ?? new MigratorKit(config ?? {}, kitOptions);
|
|
@@ -218,6 +396,29 @@ function createMigrationProcessor(options = {}) {
|
|
|
218
396
|
);
|
|
219
397
|
const flush = (ctx) => Promise.allSettled([...ctx.writes]);
|
|
220
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
|
+
|
|
221
422
|
// Subscribed once, for the processor's lifetime: the kit emits per run, and
|
|
222
423
|
// `current` says which job that run belongs to.
|
|
223
424
|
const listeners = {
|
|
@@ -246,6 +447,9 @@ function createMigrationProcessor(options = {}) {
|
|
|
246
447
|
'migration:skipped': (event) => {
|
|
247
448
|
if (current) log(current, `⏭ Skipped ${event.migration} (${event.reason ?? 'skipped'})`);
|
|
248
449
|
},
|
|
450
|
+
'background:registered': (event) => {
|
|
451
|
+
if (current) current.registered.push(event.migration);
|
|
452
|
+
},
|
|
249
453
|
'converge:start': () => {
|
|
250
454
|
if (!current) return;
|
|
251
455
|
current.started = true;
|
|
@@ -285,9 +489,60 @@ function createMigrationProcessor(options = {}) {
|
|
|
285
489
|
'converge:end': (event) => {
|
|
286
490
|
if (current && event.success) log(current, `✔ Converged ${event.changed} change(s)`);
|
|
287
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
|
+
},
|
|
288
496
|
};
|
|
289
497
|
for (const [event, listener] of Object.entries(listeners)) kit.on(event, listener);
|
|
290
498
|
|
|
499
|
+
/** The background queue options every enqueue from here shares */
|
|
500
|
+
const backgroundOptions = () => ({
|
|
501
|
+
...(background.jobOptions !== undefined ? { jobOptions: background.jobOptions } : {}),
|
|
502
|
+
...(background.stallMs !== undefined ? { stallMs: background.stallMs } : {}),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Hand what this job registered to the background queue. Never fails the
|
|
507
|
+
* job — it is applied; a coordinator that could not be added now is added
|
|
508
|
+
* by the next heal (a sync tick, a verify tick, a worker's start).
|
|
509
|
+
*/
|
|
510
|
+
async function startBackground(ctx) {
|
|
511
|
+
if (background === undefined || ctx.registered.length === 0) return undefined;
|
|
512
|
+
const started = [];
|
|
513
|
+
for (const migration of ctx.registered) {
|
|
514
|
+
try {
|
|
515
|
+
const { jobs } = await enqueueBackground(background.queue, kit, {
|
|
516
|
+
migration,
|
|
517
|
+
...backgroundOptions(),
|
|
518
|
+
});
|
|
519
|
+
for (const job of jobs) started.push({ migration, jobId: job.id });
|
|
520
|
+
} catch (error) {
|
|
521
|
+
kit.logger.warn(
|
|
522
|
+
`⚠ Could not enqueue background migration ${migration}: ${errorText(error)} — ` +
|
|
523
|
+
'the next heal will',
|
|
524
|
+
{ ...jobIds(ctx), migration, error: errorText(error) },
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const entry of started) log(ctx, `⧗ Background coordinator ${entry.jobId} enqueued`);
|
|
529
|
+
return started;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Every background migration with work to do gets its coordinator — a sync tick's heal */
|
|
533
|
+
async function healBackground() {
|
|
534
|
+
if (background === undefined) return undefined;
|
|
535
|
+
try {
|
|
536
|
+
const { jobs } = await enqueueBackground(background.queue, kit, backgroundOptions());
|
|
537
|
+
return jobs.length;
|
|
538
|
+
} catch (error) {
|
|
539
|
+
kit.logger.warn(`⚠ Background heal failed: ${errorText(error)}`, {
|
|
540
|
+
error: errorText(error),
|
|
541
|
+
});
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
291
546
|
function resultOf(ctx, rows, waitedMs) {
|
|
292
547
|
const { data } = ctx;
|
|
293
548
|
const row = rows[0];
|
|
@@ -306,6 +561,15 @@ function createMigrationProcessor(options = {}) {
|
|
|
306
561
|
async function runMigrationJob(ctx, signal) {
|
|
307
562
|
const { data } = ctx;
|
|
308
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 } : {};
|
|
309
573
|
const attempt = () =>
|
|
310
574
|
data.direction === JOB_NAMES.UP
|
|
311
575
|
? kit.up(data.migration, {
|
|
@@ -314,12 +578,18 @@ function createMigrationProcessor(options = {}) {
|
|
|
314
578
|
...(data.force ? { force: true } : {}),
|
|
315
579
|
...(data.checksum ? { checksum: data.checksum } : {}),
|
|
316
580
|
...pickActor(data),
|
|
581
|
+
...jobField,
|
|
317
582
|
})
|
|
318
|
-
: kit.down(data.migration, {
|
|
583
|
+
: kit.down(data.migration, {
|
|
584
|
+
...(ordered ? { ordered: true } : {}),
|
|
585
|
+
...pickActor(data),
|
|
586
|
+
...jobField,
|
|
587
|
+
});
|
|
319
588
|
|
|
320
589
|
try {
|
|
321
590
|
const { result, waitedMs } = await waitForLock(ctx, attempt, signal);
|
|
322
|
-
|
|
591
|
+
const started = await startBackground(ctx);
|
|
592
|
+
return { ...resultOf(ctx, result, waitedMs), ...(started ? { background: started } : {}) };
|
|
323
593
|
} catch (error) {
|
|
324
594
|
// A duplicate rollback job: the first one already reverted it. Same
|
|
325
595
|
// outcome as a duplicate `up` job, which the kit reports as skipped.
|
|
@@ -415,6 +685,9 @@ function createMigrationProcessor(options = {}) {
|
|
|
415
685
|
};
|
|
416
686
|
}
|
|
417
687
|
|
|
688
|
+
/** What the last sync tick found the line waiting for — `{ migration, waitsFor }` */
|
|
689
|
+
let lastWaiting;
|
|
690
|
+
|
|
418
691
|
async function runSyncJob(ctx) {
|
|
419
692
|
if (!queue) {
|
|
420
693
|
throw new ConfigInvalidError(
|
|
@@ -422,6 +695,9 @@ function createMigrationProcessor(options = {}) {
|
|
|
422
695
|
);
|
|
423
696
|
}
|
|
424
697
|
const { to } = ctx.data;
|
|
698
|
+
// Background migrations first: whatever this tick enqueues may wait for one.
|
|
699
|
+
const healed = await healBackground();
|
|
700
|
+
const backgroundField = healed !== undefined ? { background: { enqueued: healed } } : {};
|
|
425
701
|
// The cheap probe first: a scheduler ticks far more often than there is
|
|
426
702
|
// anything to do, and planning proper re-reads the whole directory.
|
|
427
703
|
const pending = await kit.list('pending');
|
|
@@ -433,6 +709,7 @@ function createMigrationProcessor(options = {}) {
|
|
|
433
709
|
enqueued: 0,
|
|
434
710
|
upToDate: true,
|
|
435
711
|
migrations: [],
|
|
712
|
+
...backgroundField,
|
|
436
713
|
};
|
|
437
714
|
// With `convergeAfterUp`, a tick that finds no migration still checks
|
|
438
715
|
// the declared collections — a deploy that only changed a definition
|
|
@@ -464,17 +741,34 @@ function createMigrationProcessor(options = {}) {
|
|
|
464
741
|
upToDate: false,
|
|
465
742
|
migrations: [],
|
|
466
743
|
held,
|
|
744
|
+
...backgroundField,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
// Still waiting where the last tick found it waiting, for what is still
|
|
748
|
+
// not done: said again without planning — a background migration takes
|
|
749
|
+
// hours, and planning re-reads the whole directory on every tick.
|
|
750
|
+
if (lastWaiting?.migration === pending[0].file && (await stillWaiting(lastWaiting))) {
|
|
751
|
+
return {
|
|
752
|
+
kind: 'sync',
|
|
753
|
+
groupId: null,
|
|
754
|
+
batch: null,
|
|
755
|
+
enqueued: 0,
|
|
756
|
+
upToDate: false,
|
|
757
|
+
migrations: [],
|
|
758
|
+
waiting: lastWaiting,
|
|
759
|
+
...backgroundField,
|
|
467
760
|
};
|
|
468
761
|
}
|
|
469
762
|
const group = await enqueueUp(queue, kit, {
|
|
470
763
|
...(to !== undefined ? { to } : {}),
|
|
471
764
|
...(jobOptions ? { jobOptions } : {}),
|
|
472
765
|
});
|
|
766
|
+
lastWaiting = group.waiting;
|
|
473
767
|
const migrations = [];
|
|
474
768
|
for (const job of group.jobs) migrations.push(job.migration);
|
|
475
769
|
return {
|
|
476
770
|
kind: 'sync',
|
|
477
|
-
groupId: group.
|
|
771
|
+
groupId: group.jobs.length === 0 ? null : group.groupId,
|
|
478
772
|
batch: group.batch,
|
|
479
773
|
enqueued: group.jobs.length,
|
|
480
774
|
upToDate: group.upToDate,
|
|
@@ -482,9 +776,22 @@ function createMigrationProcessor(options = {}) {
|
|
|
482
776
|
...(group.converge
|
|
483
777
|
? { converge: { jobId: group.converge.id, deduplicated: group.converge.deduplicated } }
|
|
484
778
|
: {}),
|
|
779
|
+
// Waiting is not held: `held` stays the circuit breaker on a failure.
|
|
780
|
+
...(group.waiting ? { waiting: group.waiting } : {}),
|
|
781
|
+
...backgroundField,
|
|
485
782
|
};
|
|
486
783
|
}
|
|
487
784
|
|
|
785
|
+
/** Whether a background migration `waiting` waits for is still not done */
|
|
786
|
+
async function stillWaiting(waiting) {
|
|
787
|
+
if (typeof kit.backgroundStatus !== 'function') return false;
|
|
788
|
+
for (const name of waiting.waitsFor) {
|
|
789
|
+
const status = await kit.backgroundStatus(name);
|
|
790
|
+
if (status?.status !== 'completed' || status.direction === 'revert') return true;
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
|
|
488
795
|
/**
|
|
489
796
|
* Put a job that a shutdown stopped before it started its work back at the
|
|
490
797
|
* head of the queue, and return the error that tells BullMQ so — or
|
|
@@ -524,7 +831,17 @@ function createMigrationProcessor(options = {}) {
|
|
|
524
831
|
}
|
|
525
832
|
|
|
526
833
|
async function handle(job, token, signal) {
|
|
527
|
-
const ctx = {
|
|
834
|
+
const ctx = {
|
|
835
|
+
job,
|
|
836
|
+
data: undefined,
|
|
837
|
+
runId: undefined,
|
|
838
|
+
started: false,
|
|
839
|
+
writes: new Set(),
|
|
840
|
+
registered: [],
|
|
841
|
+
userlandRows: 0,
|
|
842
|
+
userlandDropped: 0,
|
|
843
|
+
sealed: false,
|
|
844
|
+
};
|
|
528
845
|
const startedAt = Date.now();
|
|
529
846
|
const signals = [shutdownController.signal];
|
|
530
847
|
if (signal) signals.push(signal);
|
|
@@ -546,22 +863,24 @@ function createMigrationProcessor(options = {}) {
|
|
|
546
863
|
abort.addEventListener('abort', onAbort, { once: true });
|
|
547
864
|
kit.logger.debug(`▶ Job ${job?.id} (${describeJob(ctx.data)})`, {
|
|
548
865
|
...jobIds(ctx),
|
|
549
|
-
...
|
|
866
|
+
...jobSummary(ctx.data),
|
|
550
867
|
});
|
|
551
868
|
await kit.connect();
|
|
552
869
|
let result;
|
|
553
870
|
if (ctx.data.kind === 'sync') result = await runSyncJob(ctx);
|
|
554
871
|
else if (ctx.data.kind === 'converge') result = await runConvergeJob(ctx, abort);
|
|
555
872
|
else result = await runMigrationJob(ctx, abort);
|
|
873
|
+
seal(ctx);
|
|
556
874
|
progress(ctx, 'completed', ctx.runId ? { runId: ctx.runId } : {});
|
|
557
875
|
await flush(ctx);
|
|
558
876
|
kit.logger.debug(`✔ Job ${job?.id} done`, {
|
|
559
877
|
...jobIds(ctx),
|
|
560
|
-
...
|
|
878
|
+
...jobSummary(ctx.data),
|
|
561
879
|
durationMs: Date.now() - startedAt,
|
|
562
880
|
});
|
|
563
881
|
return result;
|
|
564
882
|
} catch (error) {
|
|
883
|
+
seal(ctx);
|
|
565
884
|
const requeued = await requeueOnShutdown(ctx, error, token);
|
|
566
885
|
if (requeued) throw requeued;
|
|
567
886
|
// BullMQ only retries when the job was given more than one attempt — the
|
|
@@ -624,9 +943,17 @@ function createMigrationProcessor(options = {}) {
|
|
|
624
943
|
|
|
625
944
|
module.exports = {
|
|
626
945
|
RETRYABLE_CODES,
|
|
946
|
+
UNRECOVERABLE_ERROR_NAME,
|
|
627
947
|
WAITING_ERROR_NAME,
|
|
948
|
+
DEFAULT_USERLAND_LOG_ROWS,
|
|
949
|
+
assertUserlandLogRows,
|
|
628
950
|
createMigrationProcessor,
|
|
629
951
|
isTransientForJob,
|
|
630
952
|
isRetryableError,
|
|
953
|
+
jobRefOf,
|
|
954
|
+
ownLine,
|
|
955
|
+
prepareErrorForQueue,
|
|
631
956
|
resolveProcessorOptions,
|
|
957
|
+
userlandOverflowRow,
|
|
958
|
+
userlandRow,
|
|
632
959
|
};
|