@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.
- package/CHANGELOG.md +107 -0
- package/README.md +33 -2
- package/bullmq.d.ts +449 -6
- package/index.d.ts +1010 -9
- package/migronaut.schema.json +93 -1
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +128 -14
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +605 -0
- package/src/core/background.js +1121 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migrator.js +904 -12
- package/src/core/options.js +16 -0
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +107 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- 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 };
|