@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,298 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
2
+ const { sameValue } = require('../versioning/internal.js');
3
+ const { ID_BRACKETS, bracketOfType, keysetFilter, scopeFilter } = require('./background-spec.js');
4
+ const { READ_CONCURRENCY, READ_OPTIONS } = require('./server-info.js');
5
+
6
+ /**
7
+ * The default partitioner: a background migration's documents split into
8
+ * `_id` ranges, so several lanes can work one collection at once.
9
+ *
10
+ * A partitioner has one job — spread the work — and nothing rests on it being
11
+ * exact: a document no range covers is found by the final count of what is
12
+ * left and picked up by the next pass, and two lanes that meet over a document
13
+ * are kept apart by the optimistic guard on every write. So the planner may
14
+ * sample, round and degrade freely; it only has to stay cheap.
15
+ *
16
+ * Every partitioner has the same shape (the shard-aware one too):
17
+ * `{ id, plan(ctx), batchQuery(scope, cursor, opts), advance(cursor, docs, opts),
18
+ * past(cursor, docs), writeFilter(prev), checkTransform(prev, next), stale(ctx, epoch) }`.
19
+ */
20
+
21
+ /** Server error: the operation ran out of its `maxTimeMS` */
22
+ const MAX_TIME_EXPIRED = 50;
23
+
24
+ /** How long the sample may take before the plan falls back to one partition per bracket */
25
+ const SAMPLE_TIMEOUT_MS = 30_000;
26
+
27
+ /** Ids sampled at most, whatever the settings say */
28
+ const MAX_SAMPLE = 100_000;
29
+
30
+ /** The number of partitions to aim for: one per lane with no parallelism, a few per lane otherwise */
31
+ function targetPartitions(settings, maxParallel) {
32
+ if (maxParallel === 1) return 1;
33
+ const { overPartition, maxPartitions } = settings;
34
+ return Math.max(1, Math.min(overPartition * maxParallel, maxPartitions));
35
+ }
36
+
37
+ const withHint = (hint) => (hint === undefined ? {} : { hint });
38
+
39
+ /**
40
+ * The brackets the matched documents' `_id`s fall in — one indexed `findOne`
41
+ * each (`_id` is the second key of the version index), no admin rights.
42
+ */
43
+ async function presentBrackets(collection, match, hint) {
44
+ const found = await mapLimit(ID_BRACKETS, READ_CONCURRENCY, async (bracket) => {
45
+ const doc = await collection.findOne(
46
+ { $and: [match, { _id: { $type: bracket.aliases } }] },
47
+ { projection: { _id: 1 }, ...withHint(hint), ...READ_OPTIONS },
48
+ );
49
+ return doc === null ? null : bracket.name;
50
+ });
51
+ const names = [];
52
+ for (const name of found) if (name !== null) names.push(name);
53
+ return names;
54
+ }
55
+
56
+ /**
57
+ * `$sample` walks a random cursor only while it asks for less than 5% of the
58
+ * collection; at 5% or more it scans the collection and sorts it at random —
59
+ * every document. A sample-first request stays under it.
60
+ */
61
+ const RANDOM_CURSOR_SHARE = 0.04;
62
+
63
+ /** How many documents a sample-first pass asks for: `size` matches' worth, within the random cursor */
64
+ function sampleRequest(size, ratio, total) {
65
+ const oversampled = Math.ceil(size / Math.max(ratio, 0.01));
66
+ return Math.max(1, Math.min(MAX_SAMPLE, oversampled, Math.floor(total * RANDOM_CURSOR_SHARE)));
67
+ }
68
+
69
+ /**
70
+ * A sorted sample of the matched `_id`s with their `$type`: the server sorts
71
+ * them (JavaScript cannot compare BSON across types the way the server does).
72
+ * A large match samples the collection first (a random cursor) and filters
73
+ * after, oversampling by how little of it matches; a small one filters first.
74
+ */
75
+ async function sampleIds(collection, match, { size, large, hint, maxTimeMS }) {
76
+ const project = { $project: { _id: 1, t: { $type: '$_id' } } };
77
+ const sort = { $sort: { _id: 1 } };
78
+ const pipeline = large
79
+ ? [{ $sample: { size } }, { $match: match }, project, sort]
80
+ : [{ $match: match }, { $sample: { size } }, project, sort];
81
+ return collection
82
+ .aggregate(pipeline, {
83
+ maxTimeMS,
84
+ allowDiskUse: true,
85
+ ...(large ? {} : withHint(hint)),
86
+ ...READ_OPTIONS,
87
+ })
88
+ .toArray();
89
+ }
90
+
91
+ /**
92
+ * Cut one bracket's sorted sample into `parts` ranges: boundaries at
93
+ * `sample[floor(i·m/parts)]`, duplicates dropped, the outer ranges open-ended.
94
+ * Returns `[{ scope, count }]` — `count` being the samples the range holds.
95
+ */
96
+ function sliceBracket(bracket, ids, parts) {
97
+ if (parts <= 1 || ids.length < 2) {
98
+ return [{ scope: { kind: 'id-range', bracket }, count: ids.length }];
99
+ }
100
+ const bounds = [];
101
+ for (let i = 1; i < parts; i++) {
102
+ const position = Math.floor((i * ids.length) / parts);
103
+ const candidate = ids[position];
104
+ const previous = bounds.length > 0 ? bounds[bounds.length - 1].id : ids[0];
105
+ // A boundary equal to the one before (or to the very first id) would
106
+ // make an empty range — skip it.
107
+ if (!sameValue(candidate, previous)) bounds.push({ id: candidate, position });
108
+ }
109
+ const ranges = [];
110
+ let start = 0;
111
+ let lower;
112
+ for (const bound of bounds) {
113
+ ranges.push({
114
+ scope: {
115
+ kind: 'id-range',
116
+ bracket,
117
+ ...(lower !== undefined ? { gte: lower } : {}),
118
+ lt: bound.id,
119
+ },
120
+ count: bound.position - start,
121
+ });
122
+ lower = bound.id;
123
+ start = bound.position;
124
+ }
125
+ ranges.push({
126
+ scope: { kind: 'id-range', bracket, ...(lower !== undefined ? { gte: lower } : {}) },
127
+ count: ids.length - start,
128
+ });
129
+ return ranges;
130
+ }
131
+
132
+ /** One partition per bracket present, no bounds — the plan when sampling is not worth it */
133
+ const perBracket = (brackets, estimate) =>
134
+ brackets.map((bracket) => ({
135
+ scope: { kind: 'id-range', bracket },
136
+ estimate: Math.round(estimate / brackets.length),
137
+ }));
138
+
139
+ /** Largest first: a lane starts on the work that would otherwise finish last */
140
+ function largestFirst(partitions) {
141
+ return partitions.sort((a, b) => b.estimate - a.estimate);
142
+ }
143
+
144
+ /**
145
+ * Plan the partitions of one pass. `ctx`:
146
+ * `{ collection, match, hint?, maxParallel, settings: spec.partitions, maxTimeMS? }`.
147
+ * Returns `{ epoch, method, estimate, partitions: [{ scope, estimate }], degraded? }`
148
+ * — `method` says how it was planned (`empty`, `single`, `brackets`,
149
+ * `match-first`, `sample-first`), `degraded: 'sample-timeout'` that the sample
150
+ * ran out of time.
151
+ */
152
+ async function planIdRanges({ collection, match, hint, maxParallel, settings, maxTimeMS }) {
153
+ const target = targetPartitions(settings, maxParallel);
154
+ const cap = target * settings.minPartitionDocs;
155
+ const count = await collection.countDocuments(match, {
156
+ limit: cap,
157
+ ...withHint(hint),
158
+ ...READ_OPTIONS,
159
+ });
160
+ if (count === 0) return { epoch: null, method: 'empty', estimate: 0, partitions: [] };
161
+ const parts = Math.max(1, Math.min(target, Math.ceil(count / settings.minPartitionDocs)));
162
+ const brackets = await presentBrackets(collection, match, hint);
163
+ if (brackets.length === 0) {
164
+ // Matched a moment ago, gone now — the next pass will know.
165
+ return { epoch: null, method: 'empty', estimate: 0, partitions: [] };
166
+ }
167
+ if (parts === 1) {
168
+ return {
169
+ epoch: null,
170
+ method: brackets.length === 1 ? 'single' : 'brackets',
171
+ estimate: count,
172
+ // The count stopped at its limit: there are at least that many.
173
+ ...(count >= cap ? { atLeast: true } : {}),
174
+ partitions: perBracket(brackets, count),
175
+ };
176
+ }
177
+
178
+ const large = count >= cap;
179
+ let total = count;
180
+ let ratio = 1;
181
+ if (large) {
182
+ total = Math.max(count, await collection.estimatedDocumentCount());
183
+ ratio = count / total;
184
+ }
185
+ const size = Math.min(settings.sampleSize, 100 * parts);
186
+ // A large match samples the whole collection first, oversampled by how
187
+ // little of it matches — but below the share past which `$sample` stops
188
+ // walking a random cursor and scans and sorts every document instead.
189
+ const requested = large ? sampleRequest(size, ratio, total) : size;
190
+ let sample;
191
+ try {
192
+ sample = await sampleIds(collection, match, {
193
+ size: requested,
194
+ large,
195
+ hint,
196
+ maxTimeMS: maxTimeMS ?? SAMPLE_TIMEOUT_MS,
197
+ });
198
+ } catch (error) {
199
+ if (error?.code !== MAX_TIME_EXPIRED) throw error;
200
+ return {
201
+ epoch: null,
202
+ method: 'brackets',
203
+ estimate: count,
204
+ ...(count >= cap ? { atLeast: true } : {}),
205
+ degraded: 'sample-timeout',
206
+ partitions: perBracket(brackets, count),
207
+ };
208
+ }
209
+ // How many documents match, as far as the sample can tell: the bounded
210
+ // count when it was not capped, the matched share of the sampled ones when
211
+ // it was.
212
+ const estimate = large
213
+ ? Math.max(count, Math.round(total * Math.min(1, sample.length / requested)))
214
+ : count;
215
+
216
+ // The sample grouped by bracket, in server order — it arrives sorted.
217
+ const groups = new Map();
218
+ for (const { _id: id, t } of sample) {
219
+ const bracket = bracketOfType(t);
220
+ if (!groups.has(bracket)) groups.set(bracket, []);
221
+ groups.get(bracket).push(id);
222
+ }
223
+ const partitions = [];
224
+ const sampled = Math.max(1, sample.length);
225
+ const present = new Set(brackets);
226
+ for (const bracket of ID_BRACKETS) {
227
+ const ids = groups.get(bracket.name);
228
+ if (ids === undefined) {
229
+ // In the collection but not in the sample: one open partition keeps it covered.
230
+ if (present.has(bracket.name)) {
231
+ partitions.push({ scope: { kind: 'id-range', bracket: bracket.name }, estimate: 0 });
232
+ }
233
+ continue;
234
+ }
235
+ const share = Math.max(1, Math.round((parts * ids.length) / sampled));
236
+ for (const range of sliceBracket(bracket.name, ids, bracket.splittable ? share : 1)) {
237
+ partitions.push({
238
+ scope: range.scope,
239
+ estimate: Math.round((estimate * range.count) / sampled),
240
+ });
241
+ }
242
+ }
243
+ return {
244
+ epoch: null,
245
+ method: large ? 'sample-first' : 'match-first',
246
+ estimate,
247
+ partitions: largestFirst(partitions),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * The batch query of an `_id`-range partition: its scope and the keyset after
253
+ * the last id, sorted by `_id` through the version index.
254
+ */
255
+ function idRangeBatchQuery(scope, cursor, { limit, match, hint }) {
256
+ const conditions = [match, scopeFilter(scope)];
257
+ if (cursor?.lastId !== undefined) conditions.push(keysetFilter(cursor.lastId));
258
+ return {
259
+ filter: { $and: conditions },
260
+ options: { sort: { _id: 1 }, limit, ...withHint(hint), ...READ_OPTIONS },
261
+ };
262
+ }
263
+
264
+ /** The cursor after a batch: the last id read — `null` when the partition is done */
265
+ function idRangeAdvance(cursor, docs, { limit }) {
266
+ if (docs.length < limit) return null;
267
+ return idRangePast(cursor, docs);
268
+ }
269
+
270
+ /** The cursor just past `docs` — where a batch cut short (or a document stepped over) ends */
271
+ function idRangePast(cursor, docs) {
272
+ return { ...cursor, lastId: docs[docs.length - 1]._id };
273
+ }
274
+
275
+ const idRangePartitioner = Object.freeze({
276
+ id: 'id',
277
+ plan: planIdRanges,
278
+ batchQuery: idRangeBatchQuery,
279
+ advance: idRangeAdvance,
280
+ past: idRangePast,
281
+ /** Nothing to add to the optimistic filter — `_id` is already in it */
282
+ writeFilter: () => ({}),
283
+ /** Every transformation is fine: `_id` is checked by stampedDiff itself */
284
+ checkTransform: () => null,
285
+ /** An `_id` range never goes stale */
286
+ stale: () => false,
287
+ });
288
+
289
+ module.exports = {
290
+ MAX_TIME_EXPIRED,
291
+ RANDOM_CURSOR_SHARE,
292
+ SAMPLE_TIMEOUT_MS,
293
+ idRangePartitioner,
294
+ largestFirst,
295
+ sampleRequest,
296
+ sliceBracket,
297
+ targetPartitions,
298
+ };
@@ -0,0 +1,305 @@
1
+ const { ConfigInvalidError, RunAbortedError } = require('../errors/index.js');
2
+ const { errorText } = require('../utils/error.js');
3
+ const { jitter, sleep } = require('./background-throttle.js');
4
+ const { assertSliceMs } = require('./background-spec.js');
5
+ const { watchOptions } = require('./background-watch.js');
6
+ const { MigratorKit } = require('./migrator.js');
7
+
8
+ /**
9
+ * The in-process runner: background migrations driven from inside the
10
+ * application, with no queue — `concurrency` lane loops shared by every
11
+ * runnable background migration, round-robin, each taking a slice at a time
12
+ * (never more than a migration's `maxParallel` here; the leases cap it
13
+ * across every process). Run several application instances and they share
14
+ * the work through the same leases.
15
+ *
16
+ * A lane loop never throws: a failed slice goes to `onError` and that
17
+ * migration backs off. The drift watch runs every `verifyIntervalMs`; the
18
+ * live drift watcher (change streams) runs alongside when `watch` says so —
19
+ * by default when `backgroundDrift` is `'stream'` or `'both'`.
20
+ */
21
+
22
+ const DEFAULTS = Object.freeze({
23
+ concurrency: 1,
24
+ pollIntervalMs: 5_000,
25
+ verifyIntervalMs: 600_000,
26
+ });
27
+
28
+ /** The longest a failing migration backs off for */
29
+ const MAX_BACKOFF_MS = 60_000;
30
+
31
+ function readOptions(options) {
32
+ const concurrency = options.concurrency ?? DEFAULTS.concurrency;
33
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) {
34
+ throw new ConfigInvalidError('concurrency must be an integer from 1 to 64', { concurrency });
35
+ }
36
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
37
+ if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 10) {
38
+ throw new ConfigInvalidError('pollIntervalMs must be an integer ≥ 10', { pollIntervalMs });
39
+ }
40
+ const verifyIntervalMs = options.verifyIntervalMs ?? DEFAULTS.verifyIntervalMs;
41
+ if (
42
+ verifyIntervalMs !== false &&
43
+ (!Number.isSafeInteger(verifyIntervalMs) || verifyIntervalMs < 100)
44
+ ) {
45
+ throw new ConfigInvalidError('verifyIntervalMs must be false or an integer ≥ 100', {
46
+ verifyIntervalMs,
47
+ });
48
+ }
49
+ if (options.sliceMs !== undefined) assertSliceMs(options.sliceMs);
50
+ if (options.onError !== undefined && typeof options.onError !== 'function') {
51
+ throw new ConfigInvalidError('onError must be a function');
52
+ }
53
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
54
+ throw new ConfigInvalidError('signal must be an AbortSignal');
55
+ }
56
+ if (options.kit !== undefined && !(options.kit instanceof MigratorKit)) {
57
+ throw new ConfigInvalidError('kit must be a MigratorKit');
58
+ }
59
+ const { watch } = options;
60
+ if (watch !== undefined && typeof watch !== 'boolean') {
61
+ if (watch === null || typeof watch !== 'object') {
62
+ throw new ConfigInvalidError('watch must be a boolean or the watcher options');
63
+ }
64
+ watchOptions(watch);
65
+ }
66
+ return { concurrency, pollIntervalMs, verifyIntervalMs };
67
+ }
68
+
69
+ /**
70
+ * Start the runner. `options`: `{ kit | config, kitOptions?, concurrency?,
71
+ * pollIntervalMs?, sliceMs?, verifyIntervalMs?, signal?, onError? }`.
72
+ * Returns `{ kit, running, stop() }` — `stop()` resolves once every lane
73
+ * has released its lease (at the next batch) and, when the runner made the
74
+ * kit, the kit is disconnected.
75
+ */
76
+ function startBackgroundRunner(options = {}) {
77
+ const { concurrency, pollIntervalMs, verifyIntervalMs } = readOptions(options);
78
+ const kit = options.kit ?? new MigratorKit(options.config ?? {}, options.kitOptions ?? {});
79
+ const ownsKit = options.kit === undefined;
80
+ const controller = new AbortController();
81
+ const signal = controller.signal;
82
+ const onOuterAbort = () => controller.abort(options.signal.reason);
83
+ options.signal?.addEventListener('abort', onOuterAbort, { once: true });
84
+ if (options.signal?.aborted) controller.abort(options.signal.reason);
85
+
86
+ const report = (error, migration) => {
87
+ try {
88
+ options.onError?.(error, migration);
89
+ } catch {
90
+ // A throwing onError is its own problem.
91
+ }
92
+ kit.logger.warn(
93
+ `⚠ Background runner${migration ? ` (${migration})` : ''}: ${errorText(error)}`,
94
+ { ...(migration ? { background: migration } : {}), error: errorText(error) },
95
+ );
96
+ };
97
+
98
+ const shared = {
99
+ runnable: [],
100
+ refreshedAt: -Infinity,
101
+ refreshing: undefined,
102
+ next: 0,
103
+ active: new Map(),
104
+ backoff: new Map(),
105
+ failures: new Map(),
106
+ };
107
+
108
+ /** The runnable list, refreshed at most every poll interval — by one lane at a time */
109
+ async function runnable() {
110
+ if (Date.now() - shared.refreshedAt < pollIntervalMs) return shared.runnable;
111
+ shared.refreshing ??= kit
112
+ .runnableBackground()
113
+ .then((list) => {
114
+ shared.runnable = list;
115
+ shared.refreshedAt = Date.now();
116
+ })
117
+ .finally(() => {
118
+ shared.refreshing = undefined;
119
+ });
120
+ await shared.refreshing;
121
+ return shared.runnable;
122
+ }
123
+
124
+ const forget = () => {
125
+ shared.refreshedAt = -Infinity;
126
+ };
127
+
128
+ /** The next background migration a lane may take, round-robin, or `undefined` */
129
+ function pick(list) {
130
+ const now = Date.now();
131
+ for (let i = 0; i < list.length; i++) {
132
+ const entry = list[(shared.next + i) % list.length];
133
+ if ((shared.backoff.get(entry.migration) ?? 0) > now) continue;
134
+ if ((shared.active.get(entry.migration) ?? 0) >= entry.maxParallel) continue;
135
+ shared.next = (shared.next + i + 1) % list.length;
136
+ return entry;
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ const backOff = (migration, ms) => shared.backoff.set(migration, Date.now() + jitter(ms));
142
+
143
+ async function lane() {
144
+ while (!signal.aborted) {
145
+ let entry;
146
+ try {
147
+ entry = pick(await runnable());
148
+ } catch (error) {
149
+ report(error);
150
+ await sleep(pollIntervalMs, signal).catch(() => undefined);
151
+ continue;
152
+ }
153
+ if (entry === undefined) {
154
+ await sleep(pollIntervalMs, signal).catch(() => undefined);
155
+ continue;
156
+ }
157
+ const name = entry.migration;
158
+ shared.active.set(name, (shared.active.get(name) ?? 0) + 1);
159
+ try {
160
+ await turn(name);
161
+ shared.failures.delete(name);
162
+ } catch (error) {
163
+ if (signal.aborted) break;
164
+ const failures = (shared.failures.get(name) ?? 0) + 1;
165
+ shared.failures.set(name, failures);
166
+ report(error, name);
167
+ backOff(name, Math.min(MAX_BACKOFF_MS, 1000 * 2 ** failures));
168
+ } finally {
169
+ shared.active.set(name, shared.active.get(name) - 1);
170
+ }
171
+ }
172
+ }
173
+
174
+ /** One turn on one background migration: a slice, and the coordinator when it is needed */
175
+ async function turn(name) {
176
+ const slice = await kit.runBackgroundSlice(name, {
177
+ signal,
178
+ ...(options.sliceMs !== undefined ? { sliceMs: options.sliceMs } : {}),
179
+ });
180
+ switch (slice.outcome) {
181
+ case 'yielded':
182
+ case 'lost':
183
+ case 'stopped':
184
+ return;
185
+ case 'busy':
186
+ backOff(name, slice.retryAfterMs ?? pollIntervalMs);
187
+ return;
188
+ case 'paused':
189
+ case 'cancelled':
190
+ case 'failed':
191
+ forget();
192
+ return;
193
+ default: {
194
+ // Stale (no plan yet) or exhausted (nothing left to claim): the coordinator decides.
195
+ const answer = await kit.coordinateBackground(name, { signal, driver: { kind: 'runner' } });
196
+ if (answer.next === 'done') forget();
197
+ else if (answer.next !== 'process') backOff(name, answer.retryAfterMs ?? pollIntervalMs);
198
+ // Every partition is held by a lane elsewhere: look again later, not at once.
199
+ else if (slice.outcome === 'exhausted') backOff(name, pollIntervalMs);
200
+ }
201
+ }
202
+ }
203
+
204
+ async function verifier() {
205
+ if (verifyIntervalMs === false) return;
206
+ while (!signal.aborted) {
207
+ try {
208
+ await sleep(verifyIntervalMs, signal);
209
+ } catch {
210
+ return;
211
+ }
212
+ try {
213
+ const result = await kit.verifyBackground();
214
+ if (result.drift.length > 0) forget();
215
+ } catch (error) {
216
+ report(error);
217
+ }
218
+ }
219
+ }
220
+
221
+ /** The live drift watcher, when this runner hosts one — it never throws either */
222
+ async function watcher() {
223
+ let wanted = options.watch;
224
+ try {
225
+ wanted ??= (await kit.driftMode()) !== 'poll';
226
+ if (wanted === false || signal.aborted) return undefined;
227
+ return await kit.watchBackground({
228
+ ...(typeof wanted === 'object' ? wanted : {}),
229
+ signal,
230
+ onError: (error, collection) => report(error, collection),
231
+ });
232
+ } catch (error) {
233
+ if (!signal.aborted) report(error);
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ const watching = watcher();
239
+ const work = Promise.all([...Array.from({ length: concurrency }, () => lane()), verifier()]);
240
+ let stopping;
241
+ let hosted;
242
+ watching.then((started) => {
243
+ hosted = started;
244
+ });
245
+ return {
246
+ kit,
247
+ get running() {
248
+ return !signal.aborted;
249
+ },
250
+ /** The live drift watcher this runner hosts, once started — or undefined */
251
+ get watcher() {
252
+ return hosted;
253
+ },
254
+ /**
255
+ * Stop: each lane at its next batch boundary, releasing its lease.
256
+ * `timeoutMs`: stop waiting for a lane stuck in its transformation (its
257
+ * lease expires on its own, and the next lane resumes from the last
258
+ * checkpoint) — so a shutdown is never held for good.
259
+ */
260
+ stop({ timeoutMs } = {}) {
261
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0)) {
262
+ return Promise.reject(
263
+ new ConfigInvalidError('timeoutMs must be an integer ≥ 0', { timeoutMs }),
264
+ );
265
+ }
266
+ stopping ??= (async () => {
267
+ if (!signal.aborted) {
268
+ controller.abort(
269
+ new RunAbortedError('Background runner stopped', {
270
+ reason: 'Background runner stopped',
271
+ }),
272
+ );
273
+ }
274
+ const settled = (async () => {
275
+ await (await watching)?.stop();
276
+ await work;
277
+ })();
278
+ if (timeoutMs === undefined) {
279
+ await settled;
280
+ } else {
281
+ let timer;
282
+ const timedOut = await Promise.race([
283
+ settled.then(() => false),
284
+ new Promise((resolve) => {
285
+ timer = setTimeout(() => resolve(true), timeoutMs);
286
+ }),
287
+ ]);
288
+ clearTimeout(timer);
289
+ if (timedOut) {
290
+ kit.logger.warn(
291
+ `⚠ Background runner: lanes still at work after ${timeoutMs}ms — not waiting ` +
292
+ 'for them (their leases expire, and the work resumes from the last checkpoint)',
293
+ { timeoutMs },
294
+ );
295
+ }
296
+ }
297
+ options.signal?.removeEventListener('abort', onOuterAbort);
298
+ if (ownsKit) await kit.disconnect().catch(() => undefined);
299
+ })();
300
+ return stopping;
301
+ },
302
+ };
303
+ }
304
+
305
+ module.exports = { BACKGROUND_RUNNER_DEFAULTS: DEFAULTS, startBackgroundRunner };