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