@alexify/migronaut 2.0.0 → 2.1.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 +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -1
- package/package.json +21 -5
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +36 -2
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
const { MigratorKit } = require('../core/migrator.js');
|
|
2
|
+
const { ConfigInvalidError, MigronautError } = require('../errors/index.js');
|
|
3
|
+
const { errorText } = require('../utils/error.js');
|
|
4
|
+
const { isBareFilename } = require('../utils/migration-name.js');
|
|
5
|
+
const { redactDeep, redactOutbound } = require('../utils/redact.js');
|
|
6
|
+
const {
|
|
7
|
+
DEFAULT_CONVERGE_SCHEDULER_ID,
|
|
8
|
+
DEFAULT_QUEUE_NAME,
|
|
9
|
+
DEFAULT_SCHEDULER_ID,
|
|
10
|
+
JOB_NAMES,
|
|
11
|
+
buildConvergeJobTemplate,
|
|
12
|
+
permissionsNeeded,
|
|
13
|
+
resolveAllow,
|
|
14
|
+
buildSyncJobTemplate,
|
|
15
|
+
isPlainObject,
|
|
16
|
+
} = require('./jobs.js');
|
|
17
|
+
const { createMigrationProcessor, resolveProcessorOptions } = require('./processor.js');
|
|
18
|
+
const { assertJobOptions, enqueueConverge, enqueueDown, enqueueUp } = require('./producer.js');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Twice BullMQ's default job lock: its renewal (every half) then survives a
|
|
22
|
+
* migration that keeps the event loop busy for a while, instead of the job
|
|
23
|
+
* being declared stalled and handed to another worker mid-run.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_LOCK_DURATION_MS = 60_000;
|
|
26
|
+
/** One stall is a crashed worker; a second on the same job is a pattern — fail it */
|
|
27
|
+
const DEFAULT_MAX_STALLED_COUNT = 1;
|
|
28
|
+
/** The shortest interval `schedule({ every })` accepts */
|
|
29
|
+
const MIN_SCHEDULE_EVERY_MS = 1000;
|
|
30
|
+
|
|
31
|
+
const isClass = (value) => typeof value === 'function';
|
|
32
|
+
|
|
33
|
+
/** A short string field of a job read back from Redis, or undefined — for log fields only */
|
|
34
|
+
function shortString(value) {
|
|
35
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 255 ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a failed job's log line can say about it, from the job and from the error */
|
|
39
|
+
function failedJobFields(job, error) {
|
|
40
|
+
const data = job?.data ?? {};
|
|
41
|
+
const fields = {
|
|
42
|
+
...(job?.id !== undefined ? { jobId: String(job.id) } : {}),
|
|
43
|
+
groupId: shortString(data.groupId),
|
|
44
|
+
migration: shortString(data.migration),
|
|
45
|
+
direction: shortString(data.direction),
|
|
46
|
+
runId: shortString(error?.context?.runId),
|
|
47
|
+
...(error instanceof MigronautError ? { code: error.code } : {}),
|
|
48
|
+
};
|
|
49
|
+
for (const key of Object.keys(fields)) if (fields[key] === undefined) delete fields[key];
|
|
50
|
+
return fields;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertName(value, name) {
|
|
54
|
+
// BullMQ builds its Redis keys by joining with `:` and rejects it in names.
|
|
55
|
+
if (typeof value !== 'string' || value.length === 0 || value.includes(':')) {
|
|
56
|
+
throw new ConfigInvalidError(`${name} must be a non-empty string without ':'`, {
|
|
57
|
+
[name]: value,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Migrations as a queue: one database's migrations, enqueued as one BullMQ job
|
|
64
|
+
* each and applied by a single-concurrency worker.
|
|
65
|
+
*
|
|
66
|
+
* BullMQ itself is never imported — the classes (or ready instances) come in
|
|
67
|
+
* through `options.bullmq`, the same way a Mongoose instance or a pino logger
|
|
68
|
+
* does. Whatever this object constructed, it closes; whatever was handed to it
|
|
69
|
+
* (a Queue instance, the Redis connection, a kit, its MongoClient) stays the
|
|
70
|
+
* caller's to close.
|
|
71
|
+
*/
|
|
72
|
+
class MigrationQueue {
|
|
73
|
+
#kit;
|
|
74
|
+
#ownsKit;
|
|
75
|
+
#queue;
|
|
76
|
+
#ownsQueue;
|
|
77
|
+
#WorkerClass;
|
|
78
|
+
#worker;
|
|
79
|
+
#workerStarting;
|
|
80
|
+
#queueEventsSource;
|
|
81
|
+
#queueEvents;
|
|
82
|
+
#ownsQueueEvents = false;
|
|
83
|
+
#processor;
|
|
84
|
+
#connection;
|
|
85
|
+
#queueName;
|
|
86
|
+
#prefix;
|
|
87
|
+
#jobOptions;
|
|
88
|
+
#workerOptions;
|
|
89
|
+
#telemetry;
|
|
90
|
+
#globalConcurrency;
|
|
91
|
+
#allow;
|
|
92
|
+
#closing;
|
|
93
|
+
|
|
94
|
+
constructor(options) {
|
|
95
|
+
if (!isPlainObject(options)) {
|
|
96
|
+
throw new ConfigInvalidError('createMigrationQueue options must be an object');
|
|
97
|
+
}
|
|
98
|
+
const {
|
|
99
|
+
config,
|
|
100
|
+
kit,
|
|
101
|
+
kitOptions,
|
|
102
|
+
bullmq,
|
|
103
|
+
connection,
|
|
104
|
+
queueName,
|
|
105
|
+
prefix,
|
|
106
|
+
jobOptions,
|
|
107
|
+
workerOptions = {},
|
|
108
|
+
globalConcurrency = true,
|
|
109
|
+
lockWait,
|
|
110
|
+
allow,
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
if (!isPlainObject(bullmq)) {
|
|
114
|
+
throw new ConfigInvalidError(
|
|
115
|
+
'bullmq is required — pass { Queue, Worker, QueueEvents } from your own bullmq install',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const { Queue, Worker, QueueEvents, telemetry } = bullmq;
|
|
119
|
+
// BullMQ's own telemetry object (`new BullMQOtel(…)`), handed to the Queue
|
|
120
|
+
// and the Worker untouched — it is what carries a trace from the process
|
|
121
|
+
// that enqueues to the one that applies. Nothing here looks inside it.
|
|
122
|
+
if (telemetry !== undefined && (typeof telemetry !== 'object' || telemetry === null)) {
|
|
123
|
+
throw new ConfigInvalidError(
|
|
124
|
+
'bullmq.telemetry must be a BullMQ telemetry object — e.g. new BullMQOtel(…)',
|
|
125
|
+
{ telemetry: typeof telemetry },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const queueIsInstance = isPlainObject(Queue) && typeof Queue.addBulk === 'function';
|
|
129
|
+
if (!isClass(Queue) && !queueIsInstance) {
|
|
130
|
+
throw new ConfigInvalidError('bullmq.Queue must be the Queue class or a Queue instance');
|
|
131
|
+
}
|
|
132
|
+
if (Worker !== undefined && !isClass(Worker)) {
|
|
133
|
+
throw new ConfigInvalidError('bullmq.Worker must be the Worker class');
|
|
134
|
+
}
|
|
135
|
+
const eventsIsInstance = isPlainObject(QueueEvents) && typeof QueueEvents.on === 'function';
|
|
136
|
+
if (QueueEvents !== undefined && !isClass(QueueEvents) && !eventsIsInstance) {
|
|
137
|
+
throw new ConfigInvalidError(
|
|
138
|
+
'bullmq.QueueEvents must be the QueueEvents class or a QueueEvents instance',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
// Anything this object has to construct needs somewhere to connect to.
|
|
142
|
+
const constructs = isClass(Queue) || Worker !== undefined || isClass(QueueEvents);
|
|
143
|
+
if (constructs && connection == null) {
|
|
144
|
+
throw new ConfigInvalidError(
|
|
145
|
+
'connection is required — the BullMQ connection options or your Redis client',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resolvedName =
|
|
150
|
+
queueName ?? (queueIsInstance ? Queue.name : undefined) ?? DEFAULT_QUEUE_NAME;
|
|
151
|
+
assertName(resolvedName, 'queueName');
|
|
152
|
+
// An injected Queue already says where its keys live. A Worker or
|
|
153
|
+
// QueueEvents built here on another name or prefix would listen to an
|
|
154
|
+
// empty queue: jobs that never run, a wait() that never returns.
|
|
155
|
+
const resolvedPrefix = prefix ?? (queueIsInstance ? Queue.opts?.prefix : undefined);
|
|
156
|
+
if (resolvedPrefix !== undefined) assertName(resolvedPrefix, 'prefix');
|
|
157
|
+
if (queueIsInstance) {
|
|
158
|
+
MigrationQueue.#assertSameQueue('Queue', Queue, resolvedName, resolvedPrefix);
|
|
159
|
+
}
|
|
160
|
+
if (eventsIsInstance) {
|
|
161
|
+
MigrationQueue.#assertSameQueue('QueueEvents', QueueEvents, resolvedName, resolvedPrefix);
|
|
162
|
+
}
|
|
163
|
+
assertJobOptions(jobOptions);
|
|
164
|
+
if (!isPlainObject(workerOptions)) {
|
|
165
|
+
throw new ConfigInvalidError('workerOptions must be an object');
|
|
166
|
+
}
|
|
167
|
+
MigrationQueue.#assertConcurrency(workerOptions.concurrency);
|
|
168
|
+
if (typeof globalConcurrency !== 'boolean') {
|
|
169
|
+
throw new ConfigInvalidError('globalConcurrency must be a boolean', { globalConcurrency });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.#connection = connection;
|
|
173
|
+
this.#queueName = resolvedName;
|
|
174
|
+
this.#prefix = resolvedPrefix;
|
|
175
|
+
this.#jobOptions = jobOptions;
|
|
176
|
+
this.#workerOptions = workerOptions;
|
|
177
|
+
this.#telemetry = telemetry;
|
|
178
|
+
this.#globalConcurrency = globalConcurrency;
|
|
179
|
+
this.#WorkerClass = Worker;
|
|
180
|
+
this.#queueEventsSource = QueueEvents;
|
|
181
|
+
if (eventsIsInstance) this.#queueEvents = QueueEvents;
|
|
182
|
+
|
|
183
|
+
// Everything is validated before the first thing is constructed: a Queue
|
|
184
|
+
// opens a Redis connection, and a constructor that throws after that would
|
|
185
|
+
// leave it open with nobody holding a reference to close it.
|
|
186
|
+
resolveProcessorOptions({
|
|
187
|
+
...(kit !== undefined ? { kit } : {}),
|
|
188
|
+
...(config !== undefined ? { config } : {}),
|
|
189
|
+
...(lockWait !== undefined ? { lockWait } : {}),
|
|
190
|
+
...(allow !== undefined ? { allow } : {}),
|
|
191
|
+
});
|
|
192
|
+
this.#allow = resolveAllow(allow);
|
|
193
|
+
|
|
194
|
+
this.#ownsKit = kit === undefined;
|
|
195
|
+
this.#kit = kit ?? new MigratorKit(config ?? {}, kitOptions);
|
|
196
|
+
this.#ownsQueue = !queueIsInstance;
|
|
197
|
+
this.#queue = queueIsInstance
|
|
198
|
+
? Queue
|
|
199
|
+
: new Queue(resolvedName, {
|
|
200
|
+
connection,
|
|
201
|
+
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
|
|
202
|
+
...(telemetry !== undefined ? { telemetry } : {}),
|
|
203
|
+
});
|
|
204
|
+
this.#listen(this.#queue, 'error', (error) =>
|
|
205
|
+
this.#kit.logger.error(`✖ Migration queue error: ${errorText(error)}`, {
|
|
206
|
+
queue: resolvedName,
|
|
207
|
+
error: errorText(error),
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
this.#processor = createMigrationProcessor({
|
|
211
|
+
kit: this.#kit,
|
|
212
|
+
// `sync` jobs (and the converge jobs they add) enqueue into the queue
|
|
213
|
+
// they arrived on.
|
|
214
|
+
queue: this.#queue,
|
|
215
|
+
...(lockWait !== undefined ? { lockWait } : {}),
|
|
216
|
+
...(jobOptions !== undefined ? { jobOptions } : {}),
|
|
217
|
+
...(allow !== undefined ? { allow } : {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Refuse, at the enqueue call, a request this object's own policy would
|
|
223
|
+
* refuse on the worker — a job that can only fail is better not added. The
|
|
224
|
+
* same `allow` belongs on every process that enqueues and every worker.
|
|
225
|
+
*/
|
|
226
|
+
#assertPermitted(request) {
|
|
227
|
+
for (const permission of permissionsNeeded(request)) {
|
|
228
|
+
if (!this.#allow[permission]) {
|
|
229
|
+
throw new ConfigInvalidError(
|
|
230
|
+
`${permission === 'unordered' ? 'ordered: false' : permission} is not allowed by this ` +
|
|
231
|
+
`queue (allow.${permission})`,
|
|
232
|
+
{ permission },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** An injected instance must be on the queue this object is configured for */
|
|
239
|
+
static #assertSameQueue(label, instance, name, prefix) {
|
|
240
|
+
if (typeof instance.name === 'string' && instance.name !== name) {
|
|
241
|
+
throw new ConfigInvalidError(
|
|
242
|
+
`bullmq.${label} is on queue "${instance.name}", not "${name}"`,
|
|
243
|
+
{
|
|
244
|
+
queueName: name,
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const instancePrefix = instance.opts?.prefix;
|
|
249
|
+
if (instancePrefix !== undefined && prefix !== undefined && instancePrefix !== prefix) {
|
|
250
|
+
throw new ConfigInvalidError(
|
|
251
|
+
`bullmq.${label} uses prefix "${instancePrefix}", not "${prefix}"`,
|
|
252
|
+
{ prefix },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static #assertConcurrency(concurrency) {
|
|
258
|
+
if (concurrency !== undefined && concurrency !== 1) {
|
|
259
|
+
throw new ConfigInvalidError(
|
|
260
|
+
'Worker concurrency must be 1 — migrations run one at a time, in order',
|
|
261
|
+
{ concurrency },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Subscribe if the object is an emitter — an unlistened `error` event throws */
|
|
267
|
+
#listen(emitter, event, listener) {
|
|
268
|
+
if (typeof emitter?.on === 'function') emitter.on(event, listener);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#assertOpen() {
|
|
272
|
+
if (this.#closing) {
|
|
273
|
+
throw new ConfigInvalidError('This migration queue is closed');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get kit() {
|
|
278
|
+
return this.#kit;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
get queue() {
|
|
282
|
+
return this.#queue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The worker started by {@link startWorker}, if any */
|
|
286
|
+
get worker() {
|
|
287
|
+
return this.#worker;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The QueueEvents in use — an injected instance, or the one built on first `wait()` */
|
|
291
|
+
get queueEvents() {
|
|
292
|
+
return this.#queueEvents;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
get queueName() {
|
|
296
|
+
return this.#queueName;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The function a Worker runs — for attaching to a Worker you construct yourself */
|
|
300
|
+
get processor() {
|
|
301
|
+
return this.#processor;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#ensureQueueEvents() {
|
|
305
|
+
if (this.#queueEvents) return this.#queueEvents;
|
|
306
|
+
// A connection opened after close() would have nobody to close it.
|
|
307
|
+
this.#assertOpen();
|
|
308
|
+
const QueueEvents = this.#queueEventsSource;
|
|
309
|
+
if (!isClass(QueueEvents)) return undefined;
|
|
310
|
+
this.#queueEvents = new QueueEvents(this.#queueName, {
|
|
311
|
+
connection: this.#connection,
|
|
312
|
+
...(this.#prefix !== undefined ? { prefix: this.#prefix } : {}),
|
|
313
|
+
});
|
|
314
|
+
this.#ownsQueueEvents = true;
|
|
315
|
+
this.#listen(this.#queueEvents, 'error', (error) =>
|
|
316
|
+
this.#kit.logger.error(`✖ Migration queue events error: ${errorText(error)}`, {
|
|
317
|
+
queue: this.#queueName,
|
|
318
|
+
error: errorText(error),
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
return this.#queueEvents;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#internals() {
|
|
325
|
+
return { getQueueEvents: () => this.#ensureQueueEvents() };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Enqueue pending migrations — all of them, up to `options.to`, or the one
|
|
330
|
+
* `filename` — as one job each, under a single shared batch.
|
|
331
|
+
*/
|
|
332
|
+
async enqueueUp(filename, options = {}) {
|
|
333
|
+
this.#assertOpen();
|
|
334
|
+
this.#assertPermitted({
|
|
335
|
+
kind: 'migration',
|
|
336
|
+
direction: JOB_NAMES.UP,
|
|
337
|
+
force: options?.force === true,
|
|
338
|
+
ordered: options?.ordered,
|
|
339
|
+
});
|
|
340
|
+
return enqueueUp(
|
|
341
|
+
this.#queue,
|
|
342
|
+
this.#kit,
|
|
343
|
+
{ ...options, filename, jobOptions: this.#jobOptions },
|
|
344
|
+
this.#internals(),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Enqueue a rollback — the last batch, `options.batch`, the last
|
|
350
|
+
* `options.steps`, everything after `options.to`, or the one `filename` —
|
|
351
|
+
* newest applied first.
|
|
352
|
+
*/
|
|
353
|
+
async enqueueDown(filename, options = {}) {
|
|
354
|
+
this.#assertOpen();
|
|
355
|
+
this.#assertPermitted({
|
|
356
|
+
kind: 'migration',
|
|
357
|
+
direction: JOB_NAMES.DOWN,
|
|
358
|
+
ordered: options?.ordered,
|
|
359
|
+
});
|
|
360
|
+
return enqueueDown(
|
|
361
|
+
this.#queue,
|
|
362
|
+
this.#kit,
|
|
363
|
+
{ ...options, filename, jobOptions: this.#jobOptions },
|
|
364
|
+
this.#internals(),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Enqueue a converge job: the declared collections brought to their
|
|
370
|
+
* declared state by the worker, under the MongoDB lock. By default it
|
|
371
|
+
* refuses while a migration is still pending (`ordered: false` lifts that).
|
|
372
|
+
*/
|
|
373
|
+
async enqueueConverge(options = {}) {
|
|
374
|
+
this.#assertOpen();
|
|
375
|
+
this.#assertPermitted({ kind: 'converge', ordered: options?.ordered });
|
|
376
|
+
return enqueueConverge(
|
|
377
|
+
this.#queue,
|
|
378
|
+
this.#kit,
|
|
379
|
+
{ ...options, jobOptions: this.#jobOptions },
|
|
380
|
+
this.#internals(),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Full migration status — read straight from MongoDB, not from the queue */
|
|
385
|
+
async status() {
|
|
386
|
+
this.#assertOpen();
|
|
387
|
+
return this.#kit.status();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Migrations not applied yet */
|
|
391
|
+
async pending() {
|
|
392
|
+
this.#assertOpen();
|
|
393
|
+
return this.#kit.list('pending');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async audit() {
|
|
397
|
+
this.#assertOpen();
|
|
398
|
+
return this.#kit.audit();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The current holder of the MongoDB migration lock, or null */
|
|
402
|
+
async lockInfo() {
|
|
403
|
+
this.#assertOpen();
|
|
404
|
+
return this.#kit.lockInfo();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Start the worker that applies the jobs. Needs `bullmq.Worker`. Connects to
|
|
409
|
+
* MongoDB first, so an unreachable database fails here. Concurrency is
|
|
410
|
+
* always 1, and where BullMQ supports it the queue's *global* concurrency
|
|
411
|
+
* is set to 1 too, so several pods running this take turns instead of each
|
|
412
|
+
* picking a job and queuing on the MongoDB lock. Calling it again returns
|
|
413
|
+
* the same worker.
|
|
414
|
+
*/
|
|
415
|
+
async startWorker(overrides = {}) {
|
|
416
|
+
this.#assertOpen();
|
|
417
|
+
if (!isPlainObject(overrides)) {
|
|
418
|
+
throw new ConfigInvalidError('startWorker options must be an object');
|
|
419
|
+
}
|
|
420
|
+
MigrationQueue.#assertConcurrency(overrides.concurrency);
|
|
421
|
+
if (!this.#WorkerClass) {
|
|
422
|
+
throw new ConfigInvalidError(
|
|
423
|
+
'startWorker() needs the Worker class — pass bullmq: { Queue, Worker }',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
// A failed start is not cached: a database or Redis that was briefly
|
|
427
|
+
// unreachable at boot must not leave this object unable to ever start.
|
|
428
|
+
this.#workerStarting ??= this.#startWorker(overrides).catch((error) => {
|
|
429
|
+
this.#workerStarting = undefined;
|
|
430
|
+
throw error;
|
|
431
|
+
});
|
|
432
|
+
return this.#workerStarting;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async #startWorker(overrides) {
|
|
436
|
+
// Connect first: a worker that cannot reach MongoDB should fail at boot,
|
|
437
|
+
// not on its first job — and until the config is resolved the kit's logger
|
|
438
|
+
// is only provisional, so the listeners below would ignore `logger: null`.
|
|
439
|
+
await this.#kit.connect();
|
|
440
|
+
const queue = this.#queue;
|
|
441
|
+
if (this.#globalConcurrency && typeof queue.setGlobalConcurrency === 'function') {
|
|
442
|
+
await queue.setGlobalConcurrency(1);
|
|
443
|
+
}
|
|
444
|
+
// close() may have begun while this was connecting: a Worker built now
|
|
445
|
+
// would fetch a job after shutdown, with nobody left to close it.
|
|
446
|
+
this.#assertOpen();
|
|
447
|
+
const Worker = this.#WorkerClass;
|
|
448
|
+
const worker = new Worker(this.#queueName, this.#processor, {
|
|
449
|
+
connection: this.#connection,
|
|
450
|
+
...(this.#prefix !== undefined ? { prefix: this.#prefix } : {}),
|
|
451
|
+
lockDuration: DEFAULT_LOCK_DURATION_MS,
|
|
452
|
+
maxStalledCount: DEFAULT_MAX_STALLED_COUNT,
|
|
453
|
+
// Before the worker options, so a `telemetry` given there (or to this
|
|
454
|
+
// call) still wins for the worker alone.
|
|
455
|
+
...(this.#telemetry !== undefined ? { telemetry: this.#telemetry } : {}),
|
|
456
|
+
...this.#workerOptions,
|
|
457
|
+
...overrides,
|
|
458
|
+
concurrency: 1,
|
|
459
|
+
});
|
|
460
|
+
this.#worker = worker;
|
|
461
|
+
const fields = { queue: this.#queueName };
|
|
462
|
+
this.#listen(worker, 'error', (error) =>
|
|
463
|
+
this.#kit.logger.error(`✖ Migration worker error: ${errorText(error)}`, {
|
|
464
|
+
...fields,
|
|
465
|
+
error: errorText(error),
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
this.#listen(worker, 'failed', (job, error) =>
|
|
469
|
+
this.#kit.logger.warn(
|
|
470
|
+
`✖ Migration job failed${job?.id !== undefined ? ` (${job.id})` : ''}: ${errorText(error)}`,
|
|
471
|
+
{ ...fields, ...failedJobFields(job, error), error: errorText(error) },
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
this.#listen(worker, 'stalled', (jobId) =>
|
|
475
|
+
this.#kit.logger.warn(`⚠ Migration job stalled (${jobId}) — it will be re-run`, {
|
|
476
|
+
...fields,
|
|
477
|
+
jobId: String(jobId),
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
await worker.waitUntilReady?.();
|
|
481
|
+
return worker;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Stop workers from picking up new jobs. The job in flight finishes */
|
|
485
|
+
async pause() {
|
|
486
|
+
this.#assertOpen();
|
|
487
|
+
await this.#queue.pause();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async resume() {
|
|
491
|
+
this.#assertOpen();
|
|
492
|
+
await this.#queue.resume();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A job as plain, redacted data — safe to hand to an HTTP response — or
|
|
497
|
+
* null. For the live BullMQ Job, use `queue.getJob(id)`.
|
|
498
|
+
*/
|
|
499
|
+
async getJob(id) {
|
|
500
|
+
this.#assertOpen();
|
|
501
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
502
|
+
throw new ConfigInvalidError('Job id must be a non-empty string', { id });
|
|
503
|
+
}
|
|
504
|
+
let job = await this.#queue.getJob(id);
|
|
505
|
+
if (!job) return null;
|
|
506
|
+
const state = typeof job.getState === 'function' ? await job.getState() : 'unknown';
|
|
507
|
+
// The job and its state are two reads: one that finished in between would
|
|
508
|
+
// read as finished with no outcome (no returnvalue or failedReason, the
|
|
509
|
+
// attempt not counted). A finished job no longer changes, so read it again.
|
|
510
|
+
if (state === 'completed' || state === 'failed') job = (await this.#queue.getJob(id)) ?? job;
|
|
511
|
+
return redactDeep({
|
|
512
|
+
id: String(job.id),
|
|
513
|
+
name: job.name,
|
|
514
|
+
data: job.data,
|
|
515
|
+
state,
|
|
516
|
+
progress: job.progress,
|
|
517
|
+
...(job.returnvalue != null ? { returnvalue: job.returnvalue } : {}),
|
|
518
|
+
...(job.failedReason ? { failedReason: redactOutbound(job.failedReason) } : {}),
|
|
519
|
+
attemptsMade: job.attemptsMade ?? 0,
|
|
520
|
+
...(job.timestamp !== undefined ? { timestamp: job.timestamp } : {}),
|
|
521
|
+
...(job.processedOn !== undefined ? { processedOn: job.processedOn } : {}),
|
|
522
|
+
...(job.finishedOn !== undefined ? { finishedOn: job.finishedOn } : {}),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Keep the database migrated on a schedule: every tick enqueues a `sync`
|
|
528
|
+
* job, which plans whatever is pending and enqueues it — or, with
|
|
529
|
+
* `job: 'converge'`, a converge job, on a cadence of its own (index builds
|
|
530
|
+
* often belong at night, not on every sync). Idempotent — safe to call from
|
|
531
|
+
* every instance at boot.
|
|
532
|
+
*/
|
|
533
|
+
async schedule(options = {}) {
|
|
534
|
+
this.#assertOpen();
|
|
535
|
+
if (!isPlainObject(options)) {
|
|
536
|
+
throw new ConfigInvalidError('schedule options must be an object');
|
|
537
|
+
}
|
|
538
|
+
const { job = JOB_NAMES.SYNC, every, pattern, tz, to } = options;
|
|
539
|
+
if (job !== JOB_NAMES.SYNC && job !== JOB_NAMES.CONVERGE) {
|
|
540
|
+
throw new ConfigInvalidError("schedule job must be 'sync' or 'converge'", { job });
|
|
541
|
+
}
|
|
542
|
+
const converge = job === JOB_NAMES.CONVERGE;
|
|
543
|
+
const { id = converge ? DEFAULT_CONVERGE_SCHEDULER_ID : DEFAULT_SCHEDULER_ID } = options;
|
|
544
|
+
assertName(id, 'id');
|
|
545
|
+
if (converge && to !== undefined) {
|
|
546
|
+
throw new ConfigInvalidError('to only applies to a sync schedule', { to });
|
|
547
|
+
}
|
|
548
|
+
if ((every === undefined) === (pattern === undefined)) {
|
|
549
|
+
throw new ConfigInvalidError(
|
|
550
|
+
'schedule needs exactly one of `every` (ms) or `pattern` (cron)',
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
// A tick is a job, a Redis round trip and a changelog read: a schedule
|
|
554
|
+
// faster than once a second is a typo, not a cadence.
|
|
555
|
+
if (every !== undefined && (!Number.isFinite(every) || every < MIN_SCHEDULE_EVERY_MS)) {
|
|
556
|
+
throw new ConfigInvalidError(`every must be at least ${MIN_SCHEDULE_EVERY_MS} milliseconds`, {
|
|
557
|
+
every,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (pattern !== undefined && (typeof pattern !== 'string' || pattern.length === 0)) {
|
|
561
|
+
throw new ConfigInvalidError('pattern must be a cron expression', { pattern });
|
|
562
|
+
}
|
|
563
|
+
if (tz !== undefined && (typeof tz !== 'string' || tz.length === 0)) {
|
|
564
|
+
throw new ConfigInvalidError('tz must be a time zone name', { tz });
|
|
565
|
+
}
|
|
566
|
+
if (to !== undefined && !isBareFilename(to)) {
|
|
567
|
+
throw new ConfigInvalidError('to must be a migration filename', { to });
|
|
568
|
+
}
|
|
569
|
+
if (typeof this.#queue.upsertJobScheduler !== 'function') {
|
|
570
|
+
throw new ConfigInvalidError(
|
|
571
|
+
'schedule() needs job schedulers (queue.upsertJobScheduler) — BullMQ 5.16 or newer',
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
await this.#queue.upsertJobScheduler(
|
|
575
|
+
id,
|
|
576
|
+
{ ...(every !== undefined ? { every } : { pattern }), ...(tz !== undefined ? { tz } : {}) },
|
|
577
|
+
converge
|
|
578
|
+
? buildConvergeJobTemplate({ jobOptions: this.#jobOptions })
|
|
579
|
+
: buildSyncJobTemplate({ to, jobOptions: this.#jobOptions }),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Remove a schedule — the sync one by default; pass
|
|
585
|
+
* `DEFAULT_CONVERGE_SCHEDULER_ID` (or your own id) for another. Resolves
|
|
586
|
+
* whether one existed.
|
|
587
|
+
*/
|
|
588
|
+
async unschedule(id = DEFAULT_SCHEDULER_ID) {
|
|
589
|
+
this.#assertOpen();
|
|
590
|
+
assertName(id, 'id');
|
|
591
|
+
if (typeof this.#queue.removeJobScheduler !== 'function') {
|
|
592
|
+
throw new ConfigInvalidError(
|
|
593
|
+
'unschedule() needs job schedulers (queue.removeJobScheduler) — BullMQ 5.16 or newer',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
return Boolean(await this.#queue.removeJobScheduler(id));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Shut down in dependency order: stop taking the lock, let the worker finish
|
|
601
|
+
* its job (`force` skips that wait), then close what this object created and
|
|
602
|
+
* disconnect a kit it created. Idempotent; every step is attempted, and the
|
|
603
|
+
* first failure is rethrown once they all have been.
|
|
604
|
+
*/
|
|
605
|
+
async close(options = {}) {
|
|
606
|
+
this.#closing ??= this.#close(options?.force === true);
|
|
607
|
+
return this.#closing;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async #close(force) {
|
|
611
|
+
const failures = [];
|
|
612
|
+
const attempt = async (step) => {
|
|
613
|
+
try {
|
|
614
|
+
await step();
|
|
615
|
+
} catch (error) {
|
|
616
|
+
failures.push(error);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
// The worker stops fetching first: a job the shutdown below puts back in
|
|
620
|
+
// the queue must go to the next worker, not straight back to this one.
|
|
621
|
+
const worker = this.#worker;
|
|
622
|
+
const workerClosed = worker ? attempt(() => worker.close(force)) : undefined;
|
|
623
|
+
this.#processor.shutdown('Migration queue closing');
|
|
624
|
+
await workerClosed;
|
|
625
|
+
// A worker still starting is closed too, not orphaned — and a start that
|
|
626
|
+
// failed is that call's failure, not this one's.
|
|
627
|
+
await this.#workerStarting?.catch(() => undefined);
|
|
628
|
+
if (this.#worker && this.#worker !== worker) {
|
|
629
|
+
await attempt(() => this.#worker.close(force));
|
|
630
|
+
}
|
|
631
|
+
if (this.#queueEvents && this.#ownsQueueEvents) await attempt(() => this.#queueEvents.close());
|
|
632
|
+
if (this.#ownsQueue) await attempt(() => this.#queue.close());
|
|
633
|
+
if (force) {
|
|
634
|
+
// The migration in flight is not waited for — but it keeps its
|
|
635
|
+
// connection until it ends: a kit this object created is disconnected
|
|
636
|
+
// only once the processor has settled.
|
|
637
|
+
this.#processor
|
|
638
|
+
.close()
|
|
639
|
+
.then(() => (this.#ownsKit ? this.#kit.disconnect() : undefined))
|
|
640
|
+
.catch(() => undefined);
|
|
641
|
+
} else {
|
|
642
|
+
await attempt(() => this.#processor.close());
|
|
643
|
+
if (this.#ownsKit) await attempt(() => this.#kit.disconnect());
|
|
644
|
+
}
|
|
645
|
+
if (failures.length > 0) throw failures[0];
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function createMigrationQueue(options) {
|
|
650
|
+
return new MigrationQueue(options);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
module.exports = { MigrationQueue, createMigrationQueue };
|