@alexify/migronaut 2.0.0 → 2.2.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 +436 -0
- package/README.md +235 -6
- package/bullmq.d.ts +860 -0
- package/bullmq.js +1 -0
- package/index.d.ts +888 -19
- package/migronaut.schema.json +238 -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 +632 -0
- package/src/bullmq/producer.js +427 -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 +188 -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 +164 -0
- package/src/core/audit.js +88 -3
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +396 -0
- package/src/core/config.js +130 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +686 -0
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +1024 -0
- package/src/core/index-spec.js +507 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +95 -28
- package/src/core/migrator.js +600 -287
- package/src/core/options.js +266 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/search-index-spec.js +758 -0
- package/src/core/sequence.js +134 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +60 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +212 -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 +410 -0
- package/src/utils/template.js +43 -2
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
const { ConfigInvalidError, QueueJobInvalidError } = require('../errors/index.js');
|
|
2
|
+
const { actorIssue, pickActor } = require('../utils/actor.js');
|
|
3
|
+
const { MAX_ID_LENGTH } = require('../utils/id.js');
|
|
4
|
+
const { isBareFilename } = require('../utils/migration-name.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The job contract between whoever enqueues migrations and the worker that
|
|
8
|
+
* runs them. Versioned (`v`), because the two can be different deploys of the
|
|
9
|
+
* same service.
|
|
10
|
+
*
|
|
11
|
+
* The rules (ARCHITECTURE §6.6):
|
|
12
|
+
* - a producer writes `JOB_DATA_VERSION`; a worker accepts every version from
|
|
13
|
+
* `MIN_JOB_DATA_VERSION` up to its own, so jobs already queued survive an
|
|
14
|
+
* upgrade of the workers;
|
|
15
|
+
* - a field the worker does not know is refused, never ignored — a meaning it
|
|
16
|
+
* cannot honour must not be dropped silently. So any new field that changes
|
|
17
|
+
* what a job does bumps `JOB_DATA_VERSION`, and workers are rolled out
|
|
18
|
+
* before the producers that write it (an older worker fails a newer job as
|
|
19
|
+
* QUEUE_JOB_INVALID rather than guessing);
|
|
20
|
+
* - `MIN_JOB_DATA_VERSION` only moves in a major release.
|
|
21
|
+
*/
|
|
22
|
+
const JOB_DATA_VERSION = 1;
|
|
23
|
+
const MIN_JOB_DATA_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One job name per kind of work — `up`/`down` carry one migration each, `sync`
|
|
27
|
+
* plans and enqueues what is pending, `converge` brings the declared
|
|
28
|
+
* collections to their declared state.
|
|
29
|
+
*/
|
|
30
|
+
const JOB_NAMES = Object.freeze({ UP: 'up', DOWN: 'down', SYNC: 'sync', CONVERGE: 'converge' });
|
|
31
|
+
|
|
32
|
+
const DEFAULT_QUEUE_NAME = 'migronaut';
|
|
33
|
+
/** No `:` — BullMQ rejects it in custom ids */
|
|
34
|
+
const DEFAULT_SCHEDULER_ID = 'migronaut-sync';
|
|
35
|
+
const DEFAULT_CONVERGE_SCHEDULER_ID = 'migronaut-converge';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Forced onto every migration job, over anything the caller configured. A
|
|
39
|
+
* BullMQ retry re-queues the job *behind* the ones waiting, so the migrations
|
|
40
|
+
* after it would run first and fail as blocked; transient trouble (a held
|
|
41
|
+
* lock) is retried inside the processor instead, where order is kept.
|
|
42
|
+
*/
|
|
43
|
+
const MIGRATION_JOB_OPTIONS = Object.freeze({ attempts: 1 });
|
|
44
|
+
|
|
45
|
+
/** Job options that reorder, delay or re-run jobs — each would break FIFO */
|
|
46
|
+
const FORBIDDEN_JOB_OPTIONS = Object.freeze([
|
|
47
|
+
'attempts',
|
|
48
|
+
'backoff',
|
|
49
|
+
'delay',
|
|
50
|
+
'priority',
|
|
51
|
+
'lifo',
|
|
52
|
+
'jobId',
|
|
53
|
+
'deduplication',
|
|
54
|
+
'repeat',
|
|
55
|
+
'parent',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const MAX_MIGRATION_NAME_LENGTH = 255;
|
|
59
|
+
/** A file checksum as migronaut computes it: a SHA-256 hex digest */
|
|
60
|
+
const CHECKSUM_PATTERN = /^[0-9a-f]{64}$/;
|
|
61
|
+
|
|
62
|
+
/** Every field a job of each kind may carry — anything else is refused */
|
|
63
|
+
const JOB_FIELDS = Object.freeze({
|
|
64
|
+
migration: new Set([
|
|
65
|
+
'v',
|
|
66
|
+
'direction',
|
|
67
|
+
'migration',
|
|
68
|
+
'groupId',
|
|
69
|
+
'index',
|
|
70
|
+
'total',
|
|
71
|
+
'batch',
|
|
72
|
+
'force',
|
|
73
|
+
'ordered',
|
|
74
|
+
'checksum',
|
|
75
|
+
'requestedBy',
|
|
76
|
+
'reason',
|
|
77
|
+
]),
|
|
78
|
+
sync: new Set(['v', 'kind', 'to']),
|
|
79
|
+
converge: new Set(['v', 'kind', 'groupId', 'ordered', 'requestedBy', 'reason']),
|
|
80
|
+
});
|
|
81
|
+
/** The limit every migronaut id is minted under — a producer's own check and this one agree */
|
|
82
|
+
const MAX_GROUP_ID_LENGTH = MAX_ID_LENGTH;
|
|
83
|
+
|
|
84
|
+
const isPlainObject = (value) =>
|
|
85
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
86
|
+
const isPositiveInteger = (value) => Number.isSafeInteger(value) && value > 0;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A migration name as an id fragment: letters, digits, `.`, `_` and `-` as
|
|
90
|
+
* they are; every other character as `~` and its UTF-8 bytes in hex (`a b.js`
|
|
91
|
+
* → `a~20b.js`). Reversible on purpose — two names must never share a
|
|
92
|
+
* fragment, or the second file's job would be absorbed as a duplicate of the
|
|
93
|
+
* first's.
|
|
94
|
+
*/
|
|
95
|
+
function idFragment(migration) {
|
|
96
|
+
return migration.replace(/[^A-Za-z0-9._-]/gu, (char) => {
|
|
97
|
+
let encoded = '';
|
|
98
|
+
for (const byte of Buffer.from(char, 'utf8')) {
|
|
99
|
+
encoded += `~${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
100
|
+
}
|
|
101
|
+
return encoded;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Deduplication id for one migration in one direction. BullMQ holds the key
|
|
107
|
+
* only while that job is waiting or active, so a second enqueue of the same
|
|
108
|
+
* pending file is absorbed, yet a later down → up cycle is never blocked —
|
|
109
|
+
* which a custom `jobId` would do for as long as the finished job is retained.
|
|
110
|
+
*/
|
|
111
|
+
function dedupId(direction, migration, { force = false } = {}) {
|
|
112
|
+
// A forced re-run is a request of its own: a plain job for the same file
|
|
113
|
+
// still waiting must not absorb it. (`~force` can never be part of a
|
|
114
|
+
// fragment — there `~` is always followed by two upper-case hex digits.)
|
|
115
|
+
return `${direction}${force ? '~force' : ''}-${idFragment(migration)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deduplication id for a converge job, keyed on the migration it follows. Two
|
|
120
|
+
* pods enqueueing the same deploy collapse into one converge; a later, longer
|
|
121
|
+
* deploy gets its own at its own tail — sharing one id would fold it into an
|
|
122
|
+
* earlier converge that sits in front of the new migrations, and nothing would
|
|
123
|
+
* converge after them.
|
|
124
|
+
*/
|
|
125
|
+
function convergeDedupId(after) {
|
|
126
|
+
return after === undefined ? 'converge' : `converge-after-${idFragment(after)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isGroupId = (value) =>
|
|
130
|
+
typeof value === 'string' && value.length > 0 && value.length <= MAX_GROUP_ID_LENGTH;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What a worker accepts from the queue by default. Anything that can write to
|
|
134
|
+
* Redis can enqueue, so the requests a payload can make that go beyond "apply
|
|
135
|
+
* what is pending, in order" are opt-in: re-running an applied migration
|
|
136
|
+
* (`force`) and skipping the order guard (`ordered: false`). A rollback is
|
|
137
|
+
* allowed — it is the queue's other everyday job — but can be switched off.
|
|
138
|
+
*/
|
|
139
|
+
const DEFAULT_ALLOW = Object.freeze({ down: true, force: false, unordered: false });
|
|
140
|
+
|
|
141
|
+
/** Validate an `allow` option and fill in the defaults */
|
|
142
|
+
function resolveAllow(allow) {
|
|
143
|
+
if (allow === undefined) return DEFAULT_ALLOW;
|
|
144
|
+
if (!isPlainObject(allow)) {
|
|
145
|
+
throw new ConfigInvalidError('allow must be an object', { allow: typeof allow });
|
|
146
|
+
}
|
|
147
|
+
for (const key of Object.keys(allow)) {
|
|
148
|
+
if (!(key in DEFAULT_ALLOW)) {
|
|
149
|
+
throw new ConfigInvalidError(
|
|
150
|
+
`allow.${key} is not a known permission (down, force, unordered)`,
|
|
151
|
+
{ key },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (typeof allow[key] !== 'boolean') {
|
|
155
|
+
throw new ConfigInvalidError(`allow.${key} must be a boolean`, { [key]: allow[key] });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze({ ...DEFAULT_ALLOW, ...allow });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The permissions a parsed job (or an enqueue request) needs: `'down'`, `'force'`, `'unordered'` */
|
|
162
|
+
function permissionsNeeded(data) {
|
|
163
|
+
const needed = [];
|
|
164
|
+
if (data.kind === 'migration' && data.direction === JOB_NAMES.DOWN) needed.push('down');
|
|
165
|
+
if (data.force) needed.push('force');
|
|
166
|
+
if (data.ordered === false) needed.push('unordered');
|
|
167
|
+
return needed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Refuse a well-formed job this worker is not allowed to run */
|
|
171
|
+
function assertAllowed(job, data, allow) {
|
|
172
|
+
for (const permission of permissionsNeeded(data)) {
|
|
173
|
+
if (!allow[permission]) {
|
|
174
|
+
throw new QueueJobInvalidError(
|
|
175
|
+
`Refused migration job: ${permission === 'unordered' ? 'ordered: false' : permission} ` +
|
|
176
|
+
`is not allowed by this worker (allow.${permission})`,
|
|
177
|
+
{
|
|
178
|
+
...(job?.id !== undefined ? { jobId: String(job.id) } : {}),
|
|
179
|
+
issue: 'not allowed',
|
|
180
|
+
permission,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function invalid(job, issue) {
|
|
188
|
+
return new QueueJobInvalidError(`Invalid migration job: ${issue}`, {
|
|
189
|
+
...(job?.id !== undefined ? { jobId: String(job.id) } : {}),
|
|
190
|
+
issue,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validate a job read back from the queue and return a normalized copy.
|
|
196
|
+
*
|
|
197
|
+
* Everything here is untrusted: the payload sat in Redis, where anything with
|
|
198
|
+
* write access could have put it. Nothing is passed on that was not checked —
|
|
199
|
+
* above all the migration name, which becomes a filesystem path.
|
|
200
|
+
*/
|
|
201
|
+
function parseJobData(job) {
|
|
202
|
+
if (!isPlainObject(job)) throw invalid(job, 'job is not an object');
|
|
203
|
+
const { name, data } = job;
|
|
204
|
+
if (
|
|
205
|
+
name !== JOB_NAMES.UP &&
|
|
206
|
+
name !== JOB_NAMES.DOWN &&
|
|
207
|
+
name !== JOB_NAMES.SYNC &&
|
|
208
|
+
name !== JOB_NAMES.CONVERGE
|
|
209
|
+
) {
|
|
210
|
+
throw invalid(job, 'unknown job name');
|
|
211
|
+
}
|
|
212
|
+
if (!isPlainObject(data)) throw invalid(job, 'data is not an object');
|
|
213
|
+
if (!Number.isSafeInteger(data.v) || data.v < MIN_JOB_DATA_VERSION) {
|
|
214
|
+
throw invalid(job, 'unsupported job data version');
|
|
215
|
+
}
|
|
216
|
+
if (data.v > JOB_DATA_VERSION) {
|
|
217
|
+
throw invalid(
|
|
218
|
+
job,
|
|
219
|
+
`job data version ${data.v} is newer than this worker supports (${JOB_DATA_VERSION}) — ` +
|
|
220
|
+
'roll the workers out before the producers',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const kind = name === JOB_NAMES.SYNC || name === JOB_NAMES.CONVERGE ? name : 'migration';
|
|
224
|
+
for (const key of Object.keys(data)) {
|
|
225
|
+
if (!JOB_FIELDS[kind].has(key)) {
|
|
226
|
+
throw invalid(
|
|
227
|
+
job,
|
|
228
|
+
key === 'prune'
|
|
229
|
+
? "prune is not accepted from a job — the worker's own definitions decide"
|
|
230
|
+
: `unknown field "${key}"`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const key of ['requestedBy', 'reason']) {
|
|
235
|
+
const issue = actorIssue(key, data[key]);
|
|
236
|
+
if (issue) throw invalid(job, issue);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (name === JOB_NAMES.SYNC) {
|
|
240
|
+
if (data.to !== undefined && !isBareFilename(data.to)) {
|
|
241
|
+
throw invalid(job, 'to is not a bare filename');
|
|
242
|
+
}
|
|
243
|
+
return { kind: 'sync', ...(data.to !== undefined ? { to: data.to } : {}) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (name === JOB_NAMES.CONVERGE) {
|
|
247
|
+
// No `prune`, by design: what may be dropped is decided by the
|
|
248
|
+
// definitions the worker loads, never by a payload sitting in Redis.
|
|
249
|
+
if (data.groupId !== undefined && !isGroupId(data.groupId)) {
|
|
250
|
+
throw invalid(job, 'groupId is not a short string');
|
|
251
|
+
}
|
|
252
|
+
if (data.ordered !== undefined && typeof data.ordered !== 'boolean') {
|
|
253
|
+
throw invalid(job, 'ordered is not a boolean');
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
kind: 'converge',
|
|
257
|
+
...(data.groupId !== undefined ? { groupId: data.groupId } : {}),
|
|
258
|
+
...(data.ordered !== undefined ? { ordered: data.ordered } : {}),
|
|
259
|
+
...pickActor(data),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (data.direction !== name) throw invalid(job, 'direction does not match the job name');
|
|
264
|
+
if (!isBareFilename(data.migration) || data.migration.length > MAX_MIGRATION_NAME_LENGTH) {
|
|
265
|
+
throw invalid(job, 'migration is not a bare filename');
|
|
266
|
+
}
|
|
267
|
+
if (!isGroupId(data.groupId)) {
|
|
268
|
+
throw invalid(job, 'groupId is not a short string');
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
!Number.isSafeInteger(data.index) ||
|
|
272
|
+
!Number.isSafeInteger(data.total) ||
|
|
273
|
+
data.index < 0 ||
|
|
274
|
+
data.index >= data.total
|
|
275
|
+
) {
|
|
276
|
+
throw invalid(job, 'index/total are not a valid position');
|
|
277
|
+
}
|
|
278
|
+
const isUp = name === JOB_NAMES.UP;
|
|
279
|
+
if (
|
|
280
|
+
isUp ? !isPositiveInteger(data.batch) : data.batch != null && !isPositiveInteger(data.batch)
|
|
281
|
+
) {
|
|
282
|
+
throw invalid(job, 'batch is not a positive integer');
|
|
283
|
+
}
|
|
284
|
+
if (data.force !== undefined && (data.force !== true || !isUp)) {
|
|
285
|
+
throw invalid(job, 'force is only valid as `true` on an up job');
|
|
286
|
+
}
|
|
287
|
+
if (data.ordered !== undefined && typeof data.ordered !== 'boolean') {
|
|
288
|
+
throw invalid(job, 'ordered is not a boolean');
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
data.checksum !== undefined &&
|
|
292
|
+
!(typeof data.checksum === 'string' && CHECKSUM_PATTERN.test(data.checksum))
|
|
293
|
+
) {
|
|
294
|
+
throw invalid(job, 'checksum is not a SHA-256 hex digest');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
kind: 'migration',
|
|
299
|
+
direction: name,
|
|
300
|
+
migration: data.migration,
|
|
301
|
+
groupId: data.groupId,
|
|
302
|
+
index: data.index,
|
|
303
|
+
total: data.total,
|
|
304
|
+
...(data.batch != null ? { batch: data.batch } : {}),
|
|
305
|
+
...(data.force ? { force: true } : {}),
|
|
306
|
+
...(data.ordered !== undefined ? { ordered: data.ordered } : {}),
|
|
307
|
+
...(data.checksum !== undefined ? { checksum: data.checksum } : {}),
|
|
308
|
+
...pickActor(data),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build one `up`/`down` job spec, ready for `queue.addBulk`. `ordered` is
|
|
314
|
+
* always written: a job must say how it is to run, not leave it to whatever
|
|
315
|
+
* default the worker that picks it up was configured with.
|
|
316
|
+
*/
|
|
317
|
+
function buildMigrationJob({
|
|
318
|
+
direction,
|
|
319
|
+
migration,
|
|
320
|
+
groupId,
|
|
321
|
+
index,
|
|
322
|
+
total,
|
|
323
|
+
batch,
|
|
324
|
+
force,
|
|
325
|
+
ordered = true,
|
|
326
|
+
checksum,
|
|
327
|
+
requestedBy,
|
|
328
|
+
reason,
|
|
329
|
+
}) {
|
|
330
|
+
return {
|
|
331
|
+
name: direction,
|
|
332
|
+
data: {
|
|
333
|
+
v: JOB_DATA_VERSION,
|
|
334
|
+
direction,
|
|
335
|
+
migration,
|
|
336
|
+
groupId,
|
|
337
|
+
index,
|
|
338
|
+
total,
|
|
339
|
+
...(batch != null ? { batch } : {}),
|
|
340
|
+
...(force ? { force: true } : {}),
|
|
341
|
+
ordered: ordered !== false,
|
|
342
|
+
...(checksum !== undefined ? { checksum } : {}),
|
|
343
|
+
...pickActor({ requestedBy, reason }),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Per-job options: the caller's passthrough, then the ones the contract owns */
|
|
349
|
+
function migrationJobOptions(jobOptions, direction, migration, { force = false } = {}) {
|
|
350
|
+
return {
|
|
351
|
+
...jobOptions,
|
|
352
|
+
...MIGRATION_JOB_OPTIONS,
|
|
353
|
+
deduplication: { id: dedupId(direction, migration, { force }) },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A converge job spec, ready for `queue.addBulk`: the tail of an `up` group
|
|
359
|
+
* (`after` = its last migration), or a converge of its own. `ordered: false`
|
|
360
|
+
* skips the "nothing may be pending" guard; like a migration job's, it is
|
|
361
|
+
* always written.
|
|
362
|
+
*/
|
|
363
|
+
function buildConvergeJob({ groupId, ordered, after, jobOptions, requestedBy, reason } = {}) {
|
|
364
|
+
return {
|
|
365
|
+
name: JOB_NAMES.CONVERGE,
|
|
366
|
+
data: {
|
|
367
|
+
v: JOB_DATA_VERSION,
|
|
368
|
+
kind: 'converge',
|
|
369
|
+
...(groupId !== undefined ? { groupId } : {}),
|
|
370
|
+
ordered: ordered !== false,
|
|
371
|
+
...pickActor({ requestedBy, reason }),
|
|
372
|
+
},
|
|
373
|
+
opts: {
|
|
374
|
+
...jobOptions,
|
|
375
|
+
...MIGRATION_JOB_OPTIONS,
|
|
376
|
+
deduplication: { id: convergeDedupId(after) },
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* How many finished scheduler ticks are kept when the caller's `jobOptions`
|
|
383
|
+
* say nothing. BullMQ keeps every finished job by default, and a schedule
|
|
384
|
+
* mints one per tick forever — `every: 60_000` alone is 1,440 jobs a day, in
|
|
385
|
+
* a Redis that usually runs with `noeviction`.
|
|
386
|
+
*/
|
|
387
|
+
const TICK_RETENTION = Object.freeze({
|
|
388
|
+
removeOnComplete: Object.freeze({ count: 100 }),
|
|
389
|
+
removeOnFail: Object.freeze({ count: 500 }),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The options every scheduler tick carries: the caller's `jobOptions`
|
|
394
|
+
* (retention, logging), a bounded retention where they set none, the
|
|
395
|
+
* contract's own options, and `omitContext` — which keeps the tick out of
|
|
396
|
+
* whatever trace registered the schedule: BullMQ builds each iteration from
|
|
397
|
+
* the previous job's options, so a trace context stored there would be
|
|
398
|
+
* inherited by every tick after it, and one trace would grow for as long as
|
|
399
|
+
* the schedule lives. Each tick starts its own instead; the migrations it
|
|
400
|
+
* enqueues still hang under it. A no-op for a queue without telemetry.
|
|
401
|
+
*/
|
|
402
|
+
function tickJobOptions(jobOptions = {}) {
|
|
403
|
+
return {
|
|
404
|
+
removeOnComplete: TICK_RETENTION.removeOnComplete,
|
|
405
|
+
removeOnFail: TICK_RETENTION.removeOnFail,
|
|
406
|
+
...jobOptions,
|
|
407
|
+
...MIGRATION_JOB_OPTIONS,
|
|
408
|
+
telemetry: { ...jobOptions.telemetry, omitContext: true },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The job a scheduler tick produces: plan what is pending, enqueue it */
|
|
413
|
+
function buildSyncJobTemplate({ to, jobOptions } = {}) {
|
|
414
|
+
return {
|
|
415
|
+
name: JOB_NAMES.SYNC,
|
|
416
|
+
data: { v: JOB_DATA_VERSION, kind: 'sync', ...(to !== undefined ? { to } : {}) },
|
|
417
|
+
opts: tickJobOptions(jobOptions),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The job a converge schedule produces — a tick like the `sync` one */
|
|
422
|
+
function buildConvergeJobTemplate({ jobOptions } = {}) {
|
|
423
|
+
return {
|
|
424
|
+
name: JOB_NAMES.CONVERGE,
|
|
425
|
+
data: { v: JOB_DATA_VERSION, kind: 'converge' },
|
|
426
|
+
opts: tickJobOptions(jobOptions),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
module.exports = {
|
|
431
|
+
DEFAULT_ALLOW,
|
|
432
|
+
DEFAULT_CONVERGE_SCHEDULER_ID,
|
|
433
|
+
DEFAULT_QUEUE_NAME,
|
|
434
|
+
DEFAULT_SCHEDULER_ID,
|
|
435
|
+
FORBIDDEN_JOB_OPTIONS,
|
|
436
|
+
JOB_DATA_VERSION,
|
|
437
|
+
JOB_FIELDS,
|
|
438
|
+
JOB_NAMES,
|
|
439
|
+
MIN_JOB_DATA_VERSION,
|
|
440
|
+
MIGRATION_JOB_OPTIONS,
|
|
441
|
+
TICK_RETENTION,
|
|
442
|
+
assertAllowed,
|
|
443
|
+
buildConvergeJob,
|
|
444
|
+
buildConvergeJobTemplate,
|
|
445
|
+
buildMigrationJob,
|
|
446
|
+
buildSyncJobTemplate,
|
|
447
|
+
convergeDedupId,
|
|
448
|
+
dedupId,
|
|
449
|
+
isPlainObject,
|
|
450
|
+
migrationJobOptions,
|
|
451
|
+
parseJobData,
|
|
452
|
+
permissionsNeeded,
|
|
453
|
+
resolveAllow,
|
|
454
|
+
};
|