@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -3
  3. package/bullmq.d.ts +484 -8
  4. package/index.d.ts +1264 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +9 -2
  7. package/src/bullmq/background-processor.js +541 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +348 -21
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +484 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +375 -0
  21. package/src/core/background-engine.js +849 -0
  22. package/src/core/background-kit.js +432 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +610 -0
  33. package/src/core/background.js +1127 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migration-logger.js +279 -0
  42. package/src/core/migrator.js +1027 -22
  43. package/src/core/options.js +36 -0
  44. package/src/core/run-recorder.js +6 -1
  45. package/src/core/run.js +26 -12
  46. package/src/core/runner.js +34 -8
  47. package/src/core/server-info.js +9 -2
  48. package/src/core/shard-info.js +76 -0
  49. package/src/core/versioning-spec.js +181 -0
  50. package/src/errors/index.js +88 -0
  51. package/src/index.js +16 -0
  52. package/src/utils/error.js +11 -2
  53. package/src/utils/job-ref.js +44 -0
  54. package/src/utils/loader.js +77 -9
  55. package/src/utils/migration-name.js +33 -1
  56. package/src/utils/redact.js +140 -3
  57. package/src/utils/telemetry.js +110 -0
  58. package/src/utils/template.js +62 -1
  59. package/src/versioning/config.js +155 -0
  60. package/src/versioning/document.js +326 -0
  61. package/src/versioning/index.js +50 -0
  62. package/src/versioning/internal.js +279 -0
  63. package/src/versioning/mongoose.js +151 -0
  64. package/src/versioning/occ.js +318 -0
  65. package/src/versioning/registry.js +187 -0
  66. package/src/versioning/upcaster.js +213 -0
  67. package/versioning.d.ts +666 -0
  68. package/versioning.js +1 -0
@@ -0,0 +1,432 @@
1
+ const {
2
+ BackgroundConflictError,
3
+ ChecksumMismatchError,
4
+ IrreversibleMigrationError,
5
+ RunAbortedError,
6
+ } = require('../errors/index.js');
7
+ const { errorText } = require('../utils/error.js');
8
+ const {
9
+ control,
10
+ coordinate,
11
+ failedError,
12
+ requiresStatus,
13
+ runSlice,
14
+ tryUnblock,
15
+ waitForLanes,
16
+ } = require('./background.js');
17
+ const { stillDirty } = require('./background-drift.js');
18
+ const { sleep } = require('./background-throttle.js');
19
+
20
+ /**
21
+ * The kit's background side — what MigratorKit's background methods do
22
+ * beyond plumbing: driving lanes in this process, a `down` without a
23
+ * revert, the `requires` guard, and the public views of states, partitions
24
+ * and watchers. A flow like background.js: it gets what it needs from the
25
+ * kit (`host`), built only by migrator.js:
26
+ *
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.
31
+ */
32
+
33
+ /**
34
+ * Slice errors no retry fixes: the file changed or is gone or invalid, the
35
+ * configuration or the deployment cannot run it. A lane ends on them.
36
+ */
37
+ const FATAL_SLICE_CODES = new Set([
38
+ 'CHECKSUM_MISMATCH',
39
+ 'MIGRATION_FILE_NOT_FOUND',
40
+ 'MIGRATION_INVALID_EXPORT',
41
+ 'MIGRATION_INVALID_NAME',
42
+ 'CONFIG_INVALID',
43
+ 'TRANSACTIONS_UNSUPPORTED',
44
+ ]);
45
+
46
+ /** Failed slices in a row after which a lane of runBackground gives up */
47
+ const MAX_LANE_FAILURES = 10;
48
+
49
+ /** A lane's backoff after a failed slice: 250 ms · 2ⁿ, up to this */
50
+ const MAX_LANE_BACKOFF_MS = 30_000;
51
+
52
+ /** Statuses a `down` without a revert pauses before it judges whether anything was rewritten */
53
+ const PAUSABLE = new Set(['blocked', 'pending', 'running']);
54
+
55
+ /** How recent a streaming watcher's record must be for the poll to leave its collection alone */
56
+ const STREAMING_FRESH_MS = 60_000;
57
+
58
+ /** A background migration that has rewritten documents and declares no way back */
59
+ const irreversibleBackground = (name) =>
60
+ new IrreversibleMigrationError(
61
+ `Background migration ${name} has already rewritten documents and declares no revert — ` +
62
+ 'write a background migration back instead',
63
+ { names: [name] },
64
+ );
65
+
66
+ // ─── Driving it from this process ─────────────────────────────────────────────
67
+
68
+ /**
69
+ * runBackground's loop: the coordinator and up to `concurrency` lanes until
70
+ * it is done (`untilDone`) or for one round. `inline` (a run waiting for it
71
+ * under the migration lock) cannot wait out a file that changed on disk
72
+ * since it was registered — that wait is for a deploy in progress, and this
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.
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);
83
+ const stopped = () =>
84
+ new RunAbortedError(`Stopped driving background migration ${name} — it goes on from here`, {
85
+ migration: name,
86
+ });
87
+ for (;;) {
88
+ if (signal?.aborted) throw stopped();
89
+ const answer = await coordinate(deps, name, {
90
+ signal,
91
+ driver: { kind: inline ? 'inline' : 'local' },
92
+ });
93
+ if (answer.next === 'done' || answer.next === 'superseded') break;
94
+ if (answer.next === 'process') {
95
+ const state = await host.registered(name);
96
+ const count = Math.max(1, Math.min(concurrency, state.spec?.maxParallel ?? 1));
97
+ await lanes(host, name, count, { signal, sliceMs, untilDone, jobOption });
98
+ } else {
99
+ if (inline && answer.reason === 'checksum') {
100
+ throw new ChecksumMismatchError(
101
+ `Background migration ${name} changed on disk since it was registered — it cannot ` +
102
+ 'run inline until it is pinned again (migronaut background repin)',
103
+ { migration: name, background: true },
104
+ );
105
+ }
106
+ if (!untilDone) break;
107
+ try {
108
+ await sleep(answer.retryAfterMs ?? 1000, signal);
109
+ } catch {
110
+ throw stopped();
111
+ }
112
+ }
113
+ if (!untilDone) break;
114
+ }
115
+ if (signal?.aborted) throw stopped();
116
+ const state = await host.registered(name);
117
+ if (state.status === 'failed') throw failedError(state);
118
+ return host.status(name);
119
+ }
120
+
121
+ /** `count` lanes at once: the first that fails for good stops the others, and its error is thrown */
122
+ async function lanes(host, name, count, { signal, sliceMs, untilDone, jobOption }) {
123
+ const stop = new AbortController();
124
+ const laneSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
125
+ const running = [];
126
+ for (let i = 0; i < count; i++) {
127
+ running.push(
128
+ lane(host, name, { signal: laneSignal, sliceMs, untilDone, jobOption }).catch((error) => {
129
+ if (!stop.signal.aborted) stop.abort(error);
130
+ throw error;
131
+ }),
132
+ );
133
+ }
134
+ const settled = await Promise.allSettled(running);
135
+ for (const result of settled) if (result.status === 'rejected') throw result.reason;
136
+ }
137
+
138
+ /**
139
+ * One lane: slices until nothing is left to claim (or one, without
140
+ * untilDone). A failed slice is counted on its partition (which fails after
141
+ * `maxSliceFailures` in a row) and retried after a backoff; an error no
142
+ * retry can fix — the file changed or is gone, the deployment cannot run it
143
+ * — ends the lane, and so do `MAX_LANE_FAILURES` in a row.
144
+ */
145
+ async function lane(host, name, { signal, sliceMs, untilDone, jobOption = {} }) {
146
+ let failures = 0;
147
+ for (;;) {
148
+ if (signal?.aborted) return;
149
+ const owner = host.newId();
150
+ let slice;
151
+ try {
152
+ slice = await runSlice(host.deps(owner, jobOption), name, { signal, sliceMs, owner });
153
+ failures = 0;
154
+ } catch (error) {
155
+ if (signal?.aborted) return;
156
+ failures += 1;
157
+ if (FATAL_SLICE_CODES.has(error?.code) || failures >= MAX_LANE_FAILURES) throw error;
158
+ host.logger.warn(
159
+ `⚠ Background migration ${name}: a slice failed (${errorText(error)}) — retrying`,
160
+ { background: name, runId: owner, error: errorText(error) },
161
+ );
162
+ if (!untilDone) return;
163
+ try {
164
+ await sleep(Math.min(MAX_LANE_BACKOFF_MS, 250 * 2 ** failures), signal);
165
+ } catch {
166
+ return;
167
+ }
168
+ continue;
169
+ }
170
+ if (!untilDone) return;
171
+ if (slice.outcome === 'yielded') continue;
172
+ if (slice.outcome === 'busy') {
173
+ try {
174
+ await sleep(slice.retryAfterMs ?? 1000, signal);
175
+ } catch {
176
+ return;
177
+ }
178
+ continue;
179
+ }
180
+ return;
181
+ }
182
+ }
183
+
184
+ // ─── down, and requires ───────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Whether a background migration has written anything a `down` without a
188
+ * revert could not put back: documents rewritten — or, for a step
189
+ * migration (which says how many it rewrote only when it wants to), any
190
+ * step checkpointed at all.
191
+ */
192
+ async function hasRewritten(store, name, spec) {
193
+ const { migrated, batches } = await store.progress(name);
194
+ return spec.mode === 'step' ? batches > 0 : migrated > 0;
195
+ }
196
+
197
+ /**
198
+ * `down` of a background migration without a revert: withdrawn — but only
199
+ * while nothing has been rewritten, since nothing could put it back. A plan
200
+ * never committed means no lane ever claimed anything: one conditional
201
+ * delete. Otherwise the lanes are paused and waited for first, so the check
202
+ * sees every batch they wrote; a refused `down` resumes it.
203
+ */
204
+ async function withdraw(host, name, spec, session) {
205
+ const { store } = host;
206
+ const withdrawn = { status: 'withdrawn', direction: 'forward' };
207
+ const state = await store.get(name);
208
+ if (state === null) return withdrawn;
209
+ if (await hasRewritten(store, name, spec)) throw irreversibleBackground(name);
210
+ if (
211
+ (state.generation ?? 0) === 0 &&
212
+ (await store.remove(name, { session, filter: { generation: 0 } }))
213
+ ) {
214
+ return withdrawn;
215
+ }
216
+ const deps = host.deps();
217
+ const paused = PAUSABLE.has(state.status)
218
+ ? (await control(deps, name, 'pause', { reason: 'down withdraws it' })).applied === 'changed'
219
+ : false;
220
+ const stopped = await waitForLanes(deps, name);
221
+ if (!stopped || (await hasRewritten(store, name, spec))) {
222
+ if (paused) await control(deps, name, 'resume', { reason: 'down refused' });
223
+ if (!stopped) {
224
+ throw new BackgroundConflictError(
225
+ `Background migration ${name} still has lanes at work — down cannot tell whether ` +
226
+ 'they rewrote documents; try again once they stop',
227
+ { migration: name, action: 'withdraw' },
228
+ );
229
+ }
230
+ throw irreversibleBackground(name);
231
+ }
232
+ await store.remove(name, { session });
233
+ return withdrawn;
234
+ }
235
+
236
+ /**
237
+ * The background migrations of `requires` not done yet:
238
+ * `[{ migration, status }]`. A completed one is checked against its data —
239
+ * and, unless this is only a preview (`reopen: false`), reopened when old
240
+ * shapes reappeared.
241
+ */
242
+ async function unsatisfied(host, requires, { reopen = true } = {}) {
243
+ const deps = host.deps();
244
+ const waiting = [];
245
+ for (const row of await requiresStatus(deps, requires)) {
246
+ if (!row.done) {
247
+ waiting.push({ migration: row.migration, status: row.status });
248
+ } else if (row.state !== undefined && (await stillDirty(deps, row.state))) {
249
+ if (reopen) {
250
+ await control(deps, row.migration, 'retry', { reason: 'old-shape documents reappeared' });
251
+ host.emit('background:drift', {
252
+ migration: row.migration,
253
+ source: 'requires',
254
+ action: 'reopened',
255
+ });
256
+ }
257
+ waiting.push({ migration: row.migration, status: reopen ? 'running' : 'completed' });
258
+ }
259
+ }
260
+ return waiting;
261
+ }
262
+
263
+ // ─── What it says ─────────────────────────────────────────────────────────────
264
+
265
+ /** Who drove it last — and a queue chain's round */
266
+ function coordinatorView(state) {
267
+ return state.coordinator
268
+ ? {
269
+ coordinator: {
270
+ kind: state.coordinator.kind,
271
+ ...(state.round !== undefined ? { round: state.round } : {}),
272
+ at: state.coordinator.at,
273
+ },
274
+ }
275
+ : {};
276
+ }
277
+
278
+ /** How a background migration's plan used the shard key — for its status */
279
+ function shardingView(sharding) {
280
+ let hashed = false;
281
+ for (const value of Object.values(sharding.key ?? {})) if (value === 'hashed') hashed = true;
282
+ return {
283
+ mode: sharding.mode,
284
+ ...(sharding.key !== undefined ? { shardKey: sharding.key, hashed } : {}),
285
+ ...(sharding.groups !== undefined ? { groups: sharding.groups } : {}),
286
+ };
287
+ }
288
+
289
+ /** The public view of a state document, with its current plan's partition counts */
290
+ async function stateView(store, state) {
291
+ const spec = state.spec ?? {};
292
+ const counts =
293
+ state.plan !== undefined
294
+ ? await store.partitionCounts(state._id, {
295
+ generation: state.generation,
296
+ plan: state.plan.token,
297
+ })
298
+ : undefined;
299
+ const leases = await store.leases(state._id);
300
+ return {
301
+ migration: state._id,
302
+ status: state.status,
303
+ phase: state.phase,
304
+ direction: state.direction ?? 'forward',
305
+ registration: state.registration,
306
+ mode: state.mode ?? spec.mode,
307
+ ...(state.collection !== undefined ? { collection: state.collection } : {}),
308
+ ...(spec.from !== undefined ? { from: spec.from, to: spec.to } : {}),
309
+ generation: state.generation ?? 0,
310
+ pass: state.pass ?? 0,
311
+ maxParallel: spec.maxParallel ?? 1,
312
+ transaction: Boolean(spec.transaction),
313
+ totals: { ...state.totals },
314
+ failedDocuments: (state.badIds ?? []).length,
315
+ requires: state.requires ?? [],
316
+ waitsFor: state.waitsFor ?? [],
317
+ ...(counts ? { partitions: counts } : {}),
318
+ liveLeases: leases.live,
319
+ ...coordinatorView(state),
320
+ ...(state.plan
321
+ ? {
322
+ plan: {
323
+ method: state.plan.method,
324
+ estimate: state.plan.estimate,
325
+ ...(state.plan.atLeast ? { atLeast: true } : {}),
326
+ partitions: state.plan.partitions,
327
+ ...(state.plan.degraded ? { degraded: state.plan.degraded } : {}),
328
+ },
329
+ }
330
+ : {}),
331
+ ...(state.plan?.sharding ? { sharding: shardingView(state.plan.sharding) } : {}),
332
+ registeredAt: state.registeredAt,
333
+ ...(state.startedAt ? { startedAt: state.startedAt } : {}),
334
+ ...(state.completedAt ? { completedAt: state.completedAt } : {}),
335
+ ...(state.lastProgressAt ? { lastProgressAt: state.lastProgressAt } : {}),
336
+ ...(state.lastError ? { lastError: state.lastError } : {}),
337
+ ...(state.description ? { description: state.description } : {}),
338
+ ...(state.previous ? { previous: { ...state.previous } } : {}),
339
+ };
340
+ }
341
+
342
+ /** A partition as `backgroundPartitions` shows it — its lease holder, never the token */
343
+ function partitionView(partition) {
344
+ return {
345
+ id: String(partition._id),
346
+ generation: partition.generation,
347
+ seq: partition.seq,
348
+ status: partition.status,
349
+ scope: partition.scope,
350
+ estimate: partition.estimate,
351
+ counters: { ...partition.counters },
352
+ ...(partition.group !== undefined ? { group: partition.group } : {}),
353
+ ...(partition.lease
354
+ ? {
355
+ lease: {
356
+ slot: partition.lease.slot,
357
+ owner: partition.lease.owner,
358
+ host: partition.lease.host,
359
+ pid: partition.lease.pid,
360
+ renewedAt: partition.lease.renewedAt,
361
+ },
362
+ }
363
+ : {}),
364
+ ...(partition.throttle ? { throttle: partition.throttle } : {}),
365
+ claims: partition.claims ?? 0,
366
+ reclaims: partition.reclaims ?? 0,
367
+ failures: partition.failures ?? 0,
368
+ ...(partition.lastError ? { lastError: partition.lastError } : {}),
369
+ };
370
+ }
371
+
372
+ /**
373
+ * The background migrations with work to do — blocked ones whose requires
374
+ * are met are unblocked on the way — with what a heal needs to tell a
375
+ * stalled one (live leases read for all of them at once).
376
+ */
377
+ async function runnable(host) {
378
+ const { store } = host;
379
+ const deps = host.deps();
380
+ const states = [];
381
+ for (const state of await store.list({ status: { $in: ['blocked', 'pending', 'running'] } })) {
382
+ let current = state;
383
+ if (state.status === 'blocked') current = (await tryUnblock(deps, state)) ?? state;
384
+ if (current.status !== 'blocked') states.push(current);
385
+ }
386
+ const names = [];
387
+ for (const state of states) names.push(state._id);
388
+ const live = await store.liveLeasesOf(names);
389
+ const rows = [];
390
+ for (const state of states) {
391
+ rows.push({
392
+ migration: state._id,
393
+ status: state.status,
394
+ maxParallel: state.spec?.maxParallel ?? 1,
395
+ liveLeases: live.get(state._id) ?? 0,
396
+ registeredAt: state.registeredAt,
397
+ ...(state.startedAt ? { startedAt: state.startedAt } : {}),
398
+ ...(state.lastProgressAt ? { lastProgressAt: state.lastProgressAt } : {}),
399
+ ...coordinatorView(state),
400
+ });
401
+ }
402
+ return rows;
403
+ }
404
+
405
+ /** A drift watcher's stored document as its status row */
406
+ function watchRow(row) {
407
+ return {
408
+ collection: row._id,
409
+ state: row.state ?? 'starting',
410
+ ...(row.target !== undefined ? { target: row.target } : {}),
411
+ edges: row.edges ?? [],
412
+ ...(row.leader
413
+ ? { leader: { host: row.leader.host, pid: row.leader.pid, at: row.leader.at } }
414
+ : {}),
415
+ counters: { events: 0, upgraded: 0, failed: 0, skipped: 0, ...row.counters },
416
+ ...(row.lastEventAt ? { lastEventAt: row.lastEventAt } : {}),
417
+ updatedAt: row.updatedAt,
418
+ };
419
+ }
420
+
421
+ module.exports = {
422
+ STREAMING_FRESH_MS,
423
+ drive,
424
+ hasRewritten,
425
+ irreversibleBackground,
426
+ partitionView,
427
+ runnable,
428
+ stateView,
429
+ unsatisfied,
430
+ watchRow,
431
+ withdraw,
432
+ };