@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
|
@@ -24,9 +24,10 @@ const { sleep } = require('./background-throttle.js');
|
|
|
24
24
|
* and watchers. A flow like background.js: it gets what it needs from the
|
|
25
25
|
* kit (`host`), built only by migrator.js:
|
|
26
26
|
*
|
|
27
|
-
* `{ store, deps(owner?), newId(), logger, emit(event, payload),
|
|
28
|
-
* registered(name), status(name) }` — `deps` is background.js's deps
|
|
29
|
-
*
|
|
27
|
+
* `{ store, deps(owner?, { job }?), newId(), logger, emit(event, payload),
|
|
28
|
+
* registered(name), status(name) }` — `deps` is background.js's deps (`job`:
|
|
29
|
+
* the queue job a run driving it inline works for), `registered` the state or
|
|
30
|
+
* NotAppliedError, `status` the public view.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -69,10 +70,16 @@ const irreversibleBackground = (name) =>
|
|
|
69
70
|
* it is done (`untilDone`) or for one round. `inline` (a run waiting for it
|
|
70
71
|
* under the migration lock) cannot wait out a file that changed on disk
|
|
71
72
|
* since it was registered — that wait is for a deploy in progress, and this
|
|
72
|
-
* run is the deploy — so it fails instead.
|
|
73
|
+
* run is the deploy — so it fails instead. `job`: the queue job that run works
|
|
74
|
+
* for, named on every lane's lines and `migration:log` events.
|
|
73
75
|
*/
|
|
74
|
-
async function drive(
|
|
75
|
-
|
|
76
|
+
async function drive(
|
|
77
|
+
host,
|
|
78
|
+
name,
|
|
79
|
+
{ signal, sliceMs, untilDone = true, concurrency = 1, inline, job },
|
|
80
|
+
) {
|
|
81
|
+
const jobOption = job ? { job } : {};
|
|
82
|
+
const deps = host.deps(host.newId(), jobOption);
|
|
76
83
|
const stopped = () =>
|
|
77
84
|
new RunAbortedError(`Stopped driving background migration ${name} — it goes on from here`, {
|
|
78
85
|
migration: name,
|
|
@@ -87,7 +94,7 @@ async function drive(host, name, { signal, sliceMs, untilDone = true, concurrenc
|
|
|
87
94
|
if (answer.next === 'process') {
|
|
88
95
|
const state = await host.registered(name);
|
|
89
96
|
const count = Math.max(1, Math.min(concurrency, state.spec?.maxParallel ?? 1));
|
|
90
|
-
await lanes(host, name, count, { signal, sliceMs, untilDone });
|
|
97
|
+
await lanes(host, name, count, { signal, sliceMs, untilDone, jobOption });
|
|
91
98
|
} else {
|
|
92
99
|
if (inline && answer.reason === 'checksum') {
|
|
93
100
|
throw new ChecksumMismatchError(
|
|
@@ -112,13 +119,13 @@ async function drive(host, name, { signal, sliceMs, untilDone = true, concurrenc
|
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
/** `count` lanes at once: the first that fails for good stops the others, and its error is thrown */
|
|
115
|
-
async function lanes(host, name, count, { signal, sliceMs, untilDone }) {
|
|
122
|
+
async function lanes(host, name, count, { signal, sliceMs, untilDone, jobOption }) {
|
|
116
123
|
const stop = new AbortController();
|
|
117
124
|
const laneSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
|
|
118
125
|
const running = [];
|
|
119
126
|
for (let i = 0; i < count; i++) {
|
|
120
127
|
running.push(
|
|
121
|
-
lane(host, name, { signal: laneSignal, sliceMs, untilDone }).catch((error) => {
|
|
128
|
+
lane(host, name, { signal: laneSignal, sliceMs, untilDone, jobOption }).catch((error) => {
|
|
122
129
|
if (!stop.signal.aborted) stop.abort(error);
|
|
123
130
|
throw error;
|
|
124
131
|
}),
|
|
@@ -135,14 +142,14 @@ async function lanes(host, name, count, { signal, sliceMs, untilDone }) {
|
|
|
135
142
|
* retry can fix — the file changed or is gone, the deployment cannot run it
|
|
136
143
|
* — ends the lane, and so do `MAX_LANE_FAILURES` in a row.
|
|
137
144
|
*/
|
|
138
|
-
async function lane(host, name, { signal, sliceMs, untilDone }) {
|
|
145
|
+
async function lane(host, name, { signal, sliceMs, untilDone, jobOption = {} }) {
|
|
139
146
|
let failures = 0;
|
|
140
147
|
for (;;) {
|
|
141
148
|
if (signal?.aborted) return;
|
|
142
149
|
const owner = host.newId();
|
|
143
150
|
let slice;
|
|
144
151
|
try {
|
|
145
|
-
slice = await runSlice(host.deps(owner), name, { signal, sliceMs, owner });
|
|
152
|
+
slice = await runSlice(host.deps(owner, jobOption), name, { signal, sliceMs, owner });
|
|
146
153
|
failures = 0;
|
|
147
154
|
} catch (error) {
|
|
148
155
|
if (signal?.aborted) return;
|
|
@@ -518,17 +518,22 @@ function startWatch(deps, options = {}) {
|
|
|
518
518
|
async function rewrite(job, doc) {
|
|
519
519
|
if (!job.spec.transaction) return applyBatch(job, [doc], { db: deps.db });
|
|
520
520
|
let current = doc;
|
|
521
|
+
// Each transaction — the driver's retries included — runs the
|
|
522
|
+
// transformation again; what it logs says which one it was.
|
|
523
|
+
let transactions = 0;
|
|
521
524
|
for (let attempt = 0; ; attempt++) {
|
|
522
525
|
const session = deps.client.startSession();
|
|
523
526
|
try {
|
|
524
527
|
let result;
|
|
525
528
|
await session.withTransaction(async () => {
|
|
529
|
+
transactions += 1;
|
|
526
530
|
result = await applyBatch(job, [current], {
|
|
527
531
|
db: deps.db,
|
|
528
532
|
session,
|
|
529
533
|
ctxExtra: { session, db: deps.db, client: deps.client },
|
|
530
534
|
abortOnConflict: true,
|
|
531
535
|
strict: true,
|
|
536
|
+
attempt: transactions,
|
|
532
537
|
});
|
|
533
538
|
}, transactionOptions(job.spec));
|
|
534
539
|
return result;
|
package/src/core/background.js
CHANGED
|
@@ -182,6 +182,12 @@ async function jobFor(deps, name, state, { direction } = {}) {
|
|
|
182
182
|
match,
|
|
183
183
|
...(await partitionerFor(deps, spec, dir)),
|
|
184
184
|
logger: deps.logger,
|
|
185
|
+
// For ctx.background and ctx.logger: the lane working it and its queue job
|
|
186
|
+
// (with its group when a run drives it inline).
|
|
187
|
+
...(deps.logs ? { logs: deps.logs } : {}),
|
|
188
|
+
...(deps.runId !== undefined ? { runId: deps.runId } : {}),
|
|
189
|
+
...(deps.job?.id !== undefined ? { jobId: deps.job.id } : {}),
|
|
190
|
+
...(deps.job?.groupId !== undefined ? { groupId: deps.job.groupId } : {}),
|
|
185
191
|
};
|
|
186
192
|
}
|
|
187
193
|
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
const { isPlainObject } = require('../utils/canonical.js');
|
|
2
|
+
const { errorText } = require('../utils/error.js');
|
|
3
|
+
const { jobFields } = require('../utils/job-ref.js');
|
|
4
|
+
const { BOUNDS, redactBounded, redactOutbound } = require('../utils/redact.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The logger a migration gets as `ctx.logger`, and the `migration:log` event.
|
|
8
|
+
*
|
|
9
|
+
* Every call is a line on the kit's own logger with the run's correlation
|
|
10
|
+
* bound into its fields (run id, migration, direction, batch, attempt, job) —
|
|
11
|
+
* a JSON sink can join it to the kit's lines, the changelog and the queue job
|
|
12
|
+
* without parsing anything. A call whose fields say `userland: true` is also
|
|
13
|
+
* emitted as `migration:log`, for the application to keep: migronaut stores
|
|
14
|
+
* none of it. The marker is per call, so operational noise stays in the logs
|
|
15
|
+
* and only what the author meant for their users reaches the event.
|
|
16
|
+
*
|
|
17
|
+
* Mechanism only: no kit, no decisions about where lines go — `sink` is the
|
|
18
|
+
* kit's resolved logger, `emitter` its event channel.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const MIGRATION_LOG_EVENT = 'migration:log';
|
|
22
|
+
const USERLAND = 'userland';
|
|
23
|
+
|
|
24
|
+
/** The longest message an event carries; the log line keeps the whole one */
|
|
25
|
+
const MAX_MESSAGE_LENGTH = 2048;
|
|
26
|
+
|
|
27
|
+
/** A counter, one per run (or lane slice): orders a run's events within one millisecond */
|
|
28
|
+
function sequence() {
|
|
29
|
+
let next = 0;
|
|
30
|
+
return () => {
|
|
31
|
+
next += 1;
|
|
32
|
+
return next;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The run-level correlation of an ordinary run, frozen — `ctx.run` in
|
|
38
|
+
* `beforeAll`/`afterAll`, and what every migration's own adds to.
|
|
39
|
+
* `base` holds the run id and, when the caller gave them, the job and the
|
|
40
|
+
* actor: `{ id, jobId?, groupId?, requestedBy?, reason? }`.
|
|
41
|
+
*/
|
|
42
|
+
function runInfo(base, direction) {
|
|
43
|
+
return Object.freeze({ id: base.id, direction, ...withoutId(base) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `ctx.run` of one attempt of one migration — a new frozen object per attempt */
|
|
47
|
+
function migrationRunInfo(run, { migration, batch, attempt }) {
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
...run,
|
|
50
|
+
migration,
|
|
51
|
+
...(batch !== undefined ? { batch } : {}),
|
|
52
|
+
attempt,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withoutId(base) {
|
|
57
|
+
const rest = {};
|
|
58
|
+
for (const key of Object.keys(base)) {
|
|
59
|
+
if (key !== 'id' && base[key] !== undefined) rest[key] = base[key];
|
|
60
|
+
}
|
|
61
|
+
return rest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keys of `info` a log line leaves out: who asked and why go to the event only */
|
|
65
|
+
const EVENT_ONLY = new Set(['requestedBy', 'reason']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The one mapping from a context's correlation (`ctx.run`, or a background
|
|
69
|
+
* migration's `ctx.background`) to what a log line binds and what the event
|
|
70
|
+
* carries — so the three can never disagree. `id` becomes `runId`, a
|
|
71
|
+
* background `name` becomes `migration`; undefined values are left out.
|
|
72
|
+
*/
|
|
73
|
+
function correlationOf(kind, info, direction) {
|
|
74
|
+
const event = { kind };
|
|
75
|
+
const line = {};
|
|
76
|
+
const put = (key, value) => {
|
|
77
|
+
if (value === undefined) return;
|
|
78
|
+
event[key] = value;
|
|
79
|
+
if (!EVENT_ONLY.has(key)) line[key] = value;
|
|
80
|
+
};
|
|
81
|
+
if (kind === 'background') {
|
|
82
|
+
put('runId', info.runId);
|
|
83
|
+
put('migration', info.name);
|
|
84
|
+
put('direction', direction);
|
|
85
|
+
for (const key of Object.keys(info)) {
|
|
86
|
+
if (key !== 'runId' && key !== 'name') put(key, info[key]);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
put('runId', info.id);
|
|
90
|
+
for (const key of Object.keys(info)) {
|
|
91
|
+
if (key !== 'id') put(key, info[key]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { event, line };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A log message as text: an Error by its (redacted) message, anything else stringified */
|
|
98
|
+
function messageText(msg) {
|
|
99
|
+
if (typeof msg === 'string') return msg;
|
|
100
|
+
if (msg instanceof Error) return errorText(msg);
|
|
101
|
+
return msg === undefined ? '' : String(msg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a run's loggers have already said about themselves — one debug line
|
|
106
|
+
* each for a call dropped and a call cut to the event's bounds, per run (or
|
|
107
|
+
* lane slice), not per call: `{ dropped, truncated }`.
|
|
108
|
+
*/
|
|
109
|
+
function notices() {
|
|
110
|
+
return { dropped: false, truncated: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A logger bound to one context's correlation.
|
|
115
|
+
*
|
|
116
|
+
* - `sink` — the kit's resolved logger (already guarded, `null` config → silent);
|
|
117
|
+
* - `kind` — `'migration'` or `'background'`, and `info` — the frozen
|
|
118
|
+
* `ctx.run` / `ctx.background` (`direction` given separately for background);
|
|
119
|
+
* - `emitter` — `{ wanted(), emit(payload) }`, absent where nothing may be
|
|
120
|
+
* emitted (a dry run); `nextSeq` — the run's counter;
|
|
121
|
+
* - `noticed` — the run's {@link notices}: a dropped or a cut call leaves one
|
|
122
|
+
* debug line, since an invisible one is undebuggable;
|
|
123
|
+
* - `dryRun` — marks every line, and emits nothing.
|
|
124
|
+
*
|
|
125
|
+
* Pino's own argument order — `(fields, msg)` — is accepted too.
|
|
126
|
+
*/
|
|
127
|
+
function createMigrationLogger({
|
|
128
|
+
sink,
|
|
129
|
+
kind,
|
|
130
|
+
info,
|
|
131
|
+
direction,
|
|
132
|
+
emitter,
|
|
133
|
+
nextSeq,
|
|
134
|
+
noticed = notices(),
|
|
135
|
+
dryRun = false,
|
|
136
|
+
}) {
|
|
137
|
+
const { event, line } = correlationOf(kind, info, direction);
|
|
138
|
+
if (dryRun) line.dryRun = true;
|
|
139
|
+
/** The one debug line for `what` (a key of `noticed`) — the call that caused it goes on */
|
|
140
|
+
const notice = (what, text) => {
|
|
141
|
+
if (noticed[what]) return;
|
|
142
|
+
noticed[what] = true;
|
|
143
|
+
sink.debug(text, { ...line });
|
|
144
|
+
};
|
|
145
|
+
const write = (level) => (first, second) => {
|
|
146
|
+
// Logging must never break a migration: a getter that throws, a message
|
|
147
|
+
// that cannot be stringified — the call is dropped, the run goes on.
|
|
148
|
+
try {
|
|
149
|
+
let msg = first;
|
|
150
|
+
let fields = second;
|
|
151
|
+
if (isPlainObject(first) && (second === undefined || typeof second === 'string')) {
|
|
152
|
+
msg = second;
|
|
153
|
+
fields = first;
|
|
154
|
+
}
|
|
155
|
+
const text = messageText(msg);
|
|
156
|
+
const plain = isPlainObject(fields);
|
|
157
|
+
if (plain) sink[level](text, { ...fields, ...line });
|
|
158
|
+
else if (fields === undefined) sink[level](text, { ...line });
|
|
159
|
+
else sink[level](text, { ...line, value: fields });
|
|
160
|
+
if (!plain || fields[USERLAND] !== true || !emitter || dryRun || !emitter.wanted()) return;
|
|
161
|
+
const data = redactBounded(fields, { omit: USERLAND });
|
|
162
|
+
// The event leaves the process: the values a server error quotes (an
|
|
163
|
+
// E11000's duplicate key) are masked too, as everywhere text leaves it.
|
|
164
|
+
// The log line keeps them — it is what a developer debugs with.
|
|
165
|
+
const message = redactOutbound(text);
|
|
166
|
+
const clipped = message.length > MAX_MESSAGE_LENGTH;
|
|
167
|
+
const truncated = data.truncated || clipped;
|
|
168
|
+
emitter.emit({
|
|
169
|
+
...event,
|
|
170
|
+
level,
|
|
171
|
+
msg: clipped ? `${message.slice(0, MAX_MESSAGE_LENGTH)}…` : message,
|
|
172
|
+
data: data.value,
|
|
173
|
+
at: new Date(),
|
|
174
|
+
seq: nextSeq(),
|
|
175
|
+
...(truncated ? { truncated: true } : {}),
|
|
176
|
+
});
|
|
177
|
+
if (truncated) {
|
|
178
|
+
notice(
|
|
179
|
+
'truncated',
|
|
180
|
+
'A ctx.logger call was cut to the bounds of its migration:log event ' +
|
|
181
|
+
`(message ${MAX_MESSAGE_LENGTH} characters; data ${BOUNDS.depth} levels, ` +
|
|
182
|
+
`${BOUNDS.entries} entries, ${BOUNDS.string}-character strings)`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// See above: dropped — but said once, at debug level.
|
|
187
|
+
try {
|
|
188
|
+
notice('dropped', `A ctx.logger call was dropped: ${errorText(error)}`);
|
|
189
|
+
} catch {
|
|
190
|
+
// Not even that: the thrown value cannot be described.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
return { debug: write('debug'), info: write('info'), warn: write('warn'), error: write('error') };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The log side of one ordinary run, made when it takes its id — what its
|
|
199
|
+
* migrations' contexts and the kit's own lines bind:
|
|
200
|
+
*
|
|
201
|
+
* - `job` — the queue job it works for (`{ id, groupId? }`), a copy, as a run
|
|
202
|
+
* it drives inline hands it on; `fields` — the same as log fields
|
|
203
|
+
* (`{ jobId?, groupId? }`), on every line of the kit's for the run;
|
|
204
|
+
* - `info(direction)` — `ctx.run` of the run itself (`beforeAll`/`afterAll`),
|
|
205
|
+
* made once per direction (`redo` has two);
|
|
206
|
+
* - `logger(info)` — `ctx.logger` bound to `info`;
|
|
207
|
+
* - `attempt(direction, { migration, batch, attempt })` — what one attempt
|
|
208
|
+
* of one migration adds to its context: `{ run, logger }`.
|
|
209
|
+
*
|
|
210
|
+
* Every logger of the run shares its counter and its notices, so `seq` runs
|
|
211
|
+
* across the whole run and a dropped call is said once. A logger keeps them
|
|
212
|
+
* after the run ended (a timed-out body's late call is still numbered).
|
|
213
|
+
*/
|
|
214
|
+
function createRunLog({ id, job, actor = {}, sink, emitter }) {
|
|
215
|
+
const ref =
|
|
216
|
+
job === undefined
|
|
217
|
+
? undefined
|
|
218
|
+
: { id: job.id, ...(job.groupId !== undefined ? { groupId: job.groupId } : {}) };
|
|
219
|
+
const fields = jobFields(ref);
|
|
220
|
+
const base = { id, ...fields, ...actor };
|
|
221
|
+
const nextSeq = sequence();
|
|
222
|
+
const noticed = notices();
|
|
223
|
+
const infos = new Map();
|
|
224
|
+
const info = (direction) => {
|
|
225
|
+
let run = infos.get(direction);
|
|
226
|
+
if (run === undefined) {
|
|
227
|
+
run = runInfo(base, direction);
|
|
228
|
+
infos.set(direction, run);
|
|
229
|
+
}
|
|
230
|
+
return run;
|
|
231
|
+
};
|
|
232
|
+
const logger = (bound) =>
|
|
233
|
+
createMigrationLogger({ sink, kind: 'migration', info: bound, emitter, nextSeq, noticed });
|
|
234
|
+
return {
|
|
235
|
+
job: ref,
|
|
236
|
+
fields,
|
|
237
|
+
info,
|
|
238
|
+
logger,
|
|
239
|
+
attempt(direction, { migration, batch, attempt }) {
|
|
240
|
+
const run = migrationRunInfo(info(direction), { migration, batch, attempt });
|
|
241
|
+
return { run, logger: logger(run) };
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `logs(info, direction)` for one background lane slice, live watcher or dry
|
|
248
|
+
* run: a `ctx.logger` per `ctx.background`, all sharing one counter and one
|
|
249
|
+
* set of notices. A dry run's mark every line and emit nothing.
|
|
250
|
+
*/
|
|
251
|
+
function backgroundLogs({ sink, emitter, dryRun = false }) {
|
|
252
|
+
const nextSeq = sequence();
|
|
253
|
+
const noticed = notices();
|
|
254
|
+
return (info, direction) =>
|
|
255
|
+
createMigrationLogger({
|
|
256
|
+
sink,
|
|
257
|
+
kind: 'background',
|
|
258
|
+
info,
|
|
259
|
+
direction,
|
|
260
|
+
...(dryRun ? {} : { emitter }),
|
|
261
|
+
nextSeq,
|
|
262
|
+
noticed,
|
|
263
|
+
dryRun,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
MAX_MESSAGE_LENGTH,
|
|
269
|
+
MIGRATION_LOG_EVENT,
|
|
270
|
+
USERLAND,
|
|
271
|
+
backgroundLogs,
|
|
272
|
+
correlationOf,
|
|
273
|
+
createMigrationLogger,
|
|
274
|
+
createRunLog,
|
|
275
|
+
migrationRunInfo,
|
|
276
|
+
notices,
|
|
277
|
+
runInfo,
|
|
278
|
+
sequence,
|
|
279
|
+
};
|