@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,605 @@
|
|
|
1
|
+
const { ConfigInvalidError, LockAlreadyHeldError, RunAbortedError } = require('../errors/index.js');
|
|
2
|
+
const { errorText } = require('../utils/error.js');
|
|
3
|
+
const { belowVersionFilter, versionOf } = require('../versioning/document.js');
|
|
4
|
+
const { applyBatch, transactionOptions } = require('./background-engine.js');
|
|
5
|
+
const { jitter, sleep } = require('./background-throttle.js');
|
|
6
|
+
const {
|
|
7
|
+
classifyStreamError,
|
|
8
|
+
isEnding,
|
|
9
|
+
lagOf,
|
|
10
|
+
tokenDue,
|
|
11
|
+
watchPipeline,
|
|
12
|
+
watchView,
|
|
13
|
+
} = require('./background-watch-plan.js');
|
|
14
|
+
const { STATE_SUMMARY, control, jobFor } = require('./background.js');
|
|
15
|
+
const { verify } = require('./background-drift.js');
|
|
16
|
+
const { runWithLock } = require('./lock.js');
|
|
17
|
+
const { READ_OPTIONS } = require('./server-info.js');
|
|
18
|
+
|
|
19
|
+
/** Why a follower or the watcher stopped — a typed abort reason, never a bare Error */
|
|
20
|
+
const stopReason = (reason) => new RunAbortedError(reason, { reason });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The live drift watcher: after a background migration completed, an
|
|
24
|
+
* old-shape write (an old pod, a forgotten worker) is upgraded within moments
|
|
25
|
+
* instead of at the next poll — through a change stream per collection, by
|
|
26
|
+
* the same write path a lane uses (the transform, stampedDiff, the optimistic
|
|
27
|
+
* filter, targeted writes and the shard-key guard).
|
|
28
|
+
*
|
|
29
|
+
* One leader per collection, across every process: the watcher holding the
|
|
30
|
+
* `watch:<collection>` lock (a MigrationLock of its own, so it never meets a
|
|
31
|
+
* `background:<name>` coordinator); the others retry now and then, so
|
|
32
|
+
* leadership spreads over the pods. The leader keeps its resume token in
|
|
33
|
+
* `<backgroundCollection>_watch` — at most every `checkpointMs`, and the
|
|
34
|
+
* stream's post-batch token while idle, so a quiet filtered stream does not
|
|
35
|
+
* fall off the oplog. Its first start (and a start after the history was
|
|
36
|
+
* lost) begins at "now", after one drift probe of the collection.
|
|
37
|
+
*
|
|
38
|
+
* It never upgrades with a background migration that is not completed, and
|
|
39
|
+
* stands aside on a collection while a revert is at work there. A stream that
|
|
40
|
+
* lags more than `maxLagMs` behind gives up on the backlog — the background
|
|
41
|
+
* migrations it would serve are reopened, the stream starts again from now —
|
|
42
|
+
* rather than become a slow background migration of its own.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const DEFAULTS = Object.freeze({
|
|
46
|
+
upgrade: true,
|
|
47
|
+
refreshMs: 30_000,
|
|
48
|
+
checkpointMs: 5_000,
|
|
49
|
+
leaderRetryMs: 10_000,
|
|
50
|
+
maxCollections: 16,
|
|
51
|
+
maxLagMs: 60_000,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The integer options: `[key, min, max]`. The floor of `refreshMs` keeps a
|
|
56
|
+
* watcher from re-listing every state and reopening its streams in a loop.
|
|
57
|
+
*/
|
|
58
|
+
const BOUNDS = [
|
|
59
|
+
['refreshMs', 100, 3_600_000],
|
|
60
|
+
['checkpointMs', 10, 3_600_000],
|
|
61
|
+
['leaderRetryMs', 10, 3_600_000],
|
|
62
|
+
['maxCollections', 1, 1_000],
|
|
63
|
+
['maxLagMs', 1, 86_400_000],
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Every option watchBackground takes — anything else is refused, not ignored */
|
|
67
|
+
const OPTION_KEYS = new Set([
|
|
68
|
+
...BOUNDS.map(([key]) => key),
|
|
69
|
+
'upgrade',
|
|
70
|
+
'collections',
|
|
71
|
+
'signal',
|
|
72
|
+
'onError',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Hops one event may take up a chain (v1 → v2 → v3 …) */
|
|
76
|
+
const MAX_HOPS = 16;
|
|
77
|
+
/** The longest a failing stream backs off before it reopens */
|
|
78
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
79
|
+
|
|
80
|
+
/** Validate the watcher's options and fill in the defaults */
|
|
81
|
+
function watchOptions(options) {
|
|
82
|
+
if (options === null || typeof options !== 'object') {
|
|
83
|
+
throw new ConfigInvalidError('watchBackground options must be an object');
|
|
84
|
+
}
|
|
85
|
+
for (const key of Object.keys(options)) {
|
|
86
|
+
if (!OPTION_KEYS.has(key)) {
|
|
87
|
+
throw new ConfigInvalidError(`"${key}" is not a watchBackground option`, { key });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const resolved = { ...DEFAULTS };
|
|
91
|
+
for (const [key, min, max] of BOUNDS) {
|
|
92
|
+
const value = options[key];
|
|
93
|
+
if (value === undefined) continue;
|
|
94
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
95
|
+
throw new ConfigInvalidError(`${key} must be an integer from ${min} to ${max}`, {
|
|
96
|
+
[key]: value,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
resolved[key] = value;
|
|
100
|
+
}
|
|
101
|
+
if (options.upgrade !== undefined) {
|
|
102
|
+
if (typeof options.upgrade !== 'boolean') {
|
|
103
|
+
throw new ConfigInvalidError('upgrade must be a boolean', { upgrade: options.upgrade });
|
|
104
|
+
}
|
|
105
|
+
resolved.upgrade = options.upgrade;
|
|
106
|
+
}
|
|
107
|
+
if (options.collections !== undefined) {
|
|
108
|
+
if (!Array.isArray(options.collections)) {
|
|
109
|
+
throw new ConfigInvalidError('collections must be an array of collection names');
|
|
110
|
+
}
|
|
111
|
+
for (const name of options.collections) {
|
|
112
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
113
|
+
throw new ConfigInvalidError('collections must be an array of collection names');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
resolved.collections = new Set(options.collections);
|
|
117
|
+
}
|
|
118
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
119
|
+
throw new ConfigInvalidError('signal must be an AbortSignal');
|
|
120
|
+
}
|
|
121
|
+
if (options.onError !== undefined && typeof options.onError !== 'function') {
|
|
122
|
+
throw new ConfigInvalidError('onError must be a function');
|
|
123
|
+
}
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The background migration that takes a collection to its target — the edge reaching highest */
|
|
128
|
+
function lastEdge(edges) {
|
|
129
|
+
let last;
|
|
130
|
+
for (const edge of edges.values()) if (last === undefined || edge.to > last.to) last = edge;
|
|
131
|
+
return last?.name;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How far this host's clock runs ahead of the server's, in milliseconds —
|
|
136
|
+
* from `hello`'s `localTime`, halfway through the round trip. 0 when the
|
|
137
|
+
* server will not say.
|
|
138
|
+
*/
|
|
139
|
+
async function clockSkew(db) {
|
|
140
|
+
try {
|
|
141
|
+
const before = Date.now();
|
|
142
|
+
const hello = await db.admin().command({ hello: 1 });
|
|
143
|
+
const after = Date.now();
|
|
144
|
+
const server = hello?.localTime instanceof Date ? hello.localTime.getTime() : undefined;
|
|
145
|
+
return server === undefined ? 0 : Math.round((before + after) / 2) - server;
|
|
146
|
+
} catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Start watching. `deps`: the kit's background deps plus `{ watchStore,
|
|
153
|
+
* watchLockFor(collection), owner, onDrift }`. Returns `{ running, status(),
|
|
154
|
+
* stop() }`; `stop()` resolves once every stream is closed, its last token
|
|
155
|
+
* saved and its lock released.
|
|
156
|
+
*/
|
|
157
|
+
function startWatch(deps, options = {}) {
|
|
158
|
+
const settings = watchOptions(options);
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const signal = controller.signal;
|
|
161
|
+
const onOuterAbort = () => controller.abort(options.signal.reason);
|
|
162
|
+
options.signal?.addEventListener('abort', onOuterAbort, { once: true });
|
|
163
|
+
if (options.signal?.aborted) controller.abort(options.signal.reason);
|
|
164
|
+
const owner = deps.owner;
|
|
165
|
+
/** collection → { controller, done, state, leading, counters, lastEventAt } */
|
|
166
|
+
const followers = new Map();
|
|
167
|
+
let unsupported;
|
|
168
|
+
|
|
169
|
+
const report = (error, collection) => {
|
|
170
|
+
try {
|
|
171
|
+
options.onError?.(error, collection);
|
|
172
|
+
} catch {
|
|
173
|
+
// A throwing onError is its own problem.
|
|
174
|
+
}
|
|
175
|
+
deps.logger.warn(
|
|
176
|
+
`⚠ Drift watcher${collection ? ` (${collection})` : ''}: ${errorText(error)}`,
|
|
177
|
+
deps.fields({ ...(collection ? { collection } : {}), error: errorText(error) }),
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Move a follower to `state` — said once per change, and saved when it leads */
|
|
182
|
+
async function setState(collection, state, { leading = false } = {}) {
|
|
183
|
+
const follower = followers.get(collection);
|
|
184
|
+
if (follower === undefined || follower.state === state) return;
|
|
185
|
+
follower.state = state;
|
|
186
|
+
follower.leading = leading;
|
|
187
|
+
deps.emit('background:watch', { collection, state });
|
|
188
|
+
if (leading) {
|
|
189
|
+
await deps.watchStore.save(collection, owner, { fields: { state } }).catch((error) => {
|
|
190
|
+
// The state is shown again with the next save; a lost one is only said.
|
|
191
|
+
deps.logger.debug(
|
|
192
|
+
`Drift watcher (${collection}): could not save its state: ${errorText(error)}`,
|
|
193
|
+
deps.fields({ collection }),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The collections to follow now, at most `maxCollections` */
|
|
200
|
+
function wantedCollections(states) {
|
|
201
|
+
const names = new Set();
|
|
202
|
+
for (const state of states) {
|
|
203
|
+
const spec = state.spec;
|
|
204
|
+
if (state.status !== 'completed' || state.direction === 'revert') continue;
|
|
205
|
+
if (spec?.mode !== 'declarative') continue;
|
|
206
|
+
if (settings.collections !== undefined && !settings.collections.has(spec.collection)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
names.add(spec.collection);
|
|
210
|
+
}
|
|
211
|
+
const sorted = [...names].sort();
|
|
212
|
+
if (sorted.length > settings.maxCollections) {
|
|
213
|
+
const left = sorted.length - settings.maxCollections;
|
|
214
|
+
if (!deps.warned?.has('watch:max-collections')) {
|
|
215
|
+
deps.warned?.add('watch:max-collections');
|
|
216
|
+
deps.logger.warn(
|
|
217
|
+
`⚠ Drift watcher: ${left} collection(s) past maxCollections (${settings.maxCollections}) ` +
|
|
218
|
+
'are left to the polling watch',
|
|
219
|
+
deps.fields({ maxCollections: settings.maxCollections }),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return new Set(sorted.slice(0, settings.maxCollections));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function supervise() {
|
|
227
|
+
while (!signal.aborted) {
|
|
228
|
+
// A follower found no change streams here at all: nothing to supervise.
|
|
229
|
+
if (unsupported !== undefined) break;
|
|
230
|
+
try {
|
|
231
|
+
const wanted = wantedCollections(await deps.store.list({}, { projection: STATE_SUMMARY }));
|
|
232
|
+
for (const collection of wanted) {
|
|
233
|
+
if (!followers.has(collection)) startFollower(collection);
|
|
234
|
+
}
|
|
235
|
+
for (const [collection, follower] of followers) {
|
|
236
|
+
if (!wanted.has(collection)) follower.controller.abort(stopReason('no longer watched'));
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (signal.aborted) break;
|
|
240
|
+
report(error);
|
|
241
|
+
}
|
|
242
|
+
await sleep(settings.refreshMs, signal).catch(() => undefined);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function startFollower(collection) {
|
|
247
|
+
const child = new AbortController();
|
|
248
|
+
const follower = {
|
|
249
|
+
controller: child,
|
|
250
|
+
state: undefined,
|
|
251
|
+
leading: false,
|
|
252
|
+
counters: { events: 0, upgraded: 0, failed: 0, skipped: 0 },
|
|
253
|
+
lastEventAt: undefined,
|
|
254
|
+
};
|
|
255
|
+
followers.set(collection, follower);
|
|
256
|
+
const both = AbortSignal.any([signal, child.signal]);
|
|
257
|
+
follower.done = follow(collection, both)
|
|
258
|
+
.catch((error) => report(error, collection))
|
|
259
|
+
.finally(async () => {
|
|
260
|
+
await setState(collection, 'stopped');
|
|
261
|
+
followers.delete(collection);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Lead the collection when its lock is free; follow (and retry) when it is not */
|
|
266
|
+
async function follow(collection, followSignal) {
|
|
267
|
+
while (!followSignal.aborted) {
|
|
268
|
+
let outcome;
|
|
269
|
+
try {
|
|
270
|
+
outcome = await runWithLock(
|
|
271
|
+
deps.watchLockFor(collection),
|
|
272
|
+
{ logger: deps.logger, owner },
|
|
273
|
+
(lockSignal) => lead(collection, AbortSignal.any([followSignal, lockSignal])),
|
|
274
|
+
);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (followSignal.aborted) return;
|
|
277
|
+
if (error instanceof LockAlreadyHeldError) {
|
|
278
|
+
await setState(collection, 'following');
|
|
279
|
+
} else {
|
|
280
|
+
report(error, collection);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (outcome === 'fallback' || outcome === 'gone') return;
|
|
284
|
+
if (outcome === 'unsupported') {
|
|
285
|
+
unsupported = outcome;
|
|
286
|
+
controller.abort(stopReason('change streams are not supported here'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await sleep(jitter(settings.leaderRetryMs), followSignal).catch(() => undefined);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The leader's loop: open the stream, serve it, reopen it — until stopped */
|
|
294
|
+
async function lead(collection, leadSignal) {
|
|
295
|
+
await deps.watchStore.lead(collection, owner);
|
|
296
|
+
const stored = await deps.watchStore.get(collection);
|
|
297
|
+
let token = stored?.resumeToken;
|
|
298
|
+
let failures = 0;
|
|
299
|
+
while (!leadSignal.aborted) {
|
|
300
|
+
const states = await deps.store.list({}, { projection: STATE_SUMMARY });
|
|
301
|
+
const { suspended, edges, target, byName } = watchView(states, collection);
|
|
302
|
+
if (suspended !== undefined) {
|
|
303
|
+
await setState(collection, 'suspended', { leading: true });
|
|
304
|
+
await sleep(settings.refreshMs, leadSignal).catch(() => undefined);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (target === undefined) return 'gone';
|
|
308
|
+
const field = byName.get(edges.values().next().value.name).spec.field;
|
|
309
|
+
const outcome = await serve(collection, leadSignal, { edges, byName, target, field, token });
|
|
310
|
+
token = outcome.token;
|
|
311
|
+
if (outcome.end !== undefined) return outcome.end;
|
|
312
|
+
if (outcome.error === undefined) {
|
|
313
|
+
failures = 0;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const kind = classifyStreamError(outcome.error);
|
|
317
|
+
if (kind === 'unsupported') return 'unsupported';
|
|
318
|
+
if (kind === 'unauthorized') {
|
|
319
|
+
await setState(collection, 'fallback', { leading: true });
|
|
320
|
+
report(outcome.error, collection);
|
|
321
|
+
return 'fallback';
|
|
322
|
+
}
|
|
323
|
+
if (kind === 'history-lost') {
|
|
324
|
+
token = undefined;
|
|
325
|
+
await deps.watchStore.save(collection, owner, { unset: ['resumeToken'] });
|
|
326
|
+
await setState(collection, 'history-lost', { leading: true });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
failures += 1;
|
|
330
|
+
report(outcome.error, collection);
|
|
331
|
+
await sleep(Math.min(MAX_BACKOFF_MS, 250 * 2 ** failures), leadSignal).catch(() => undefined);
|
|
332
|
+
}
|
|
333
|
+
return 'stopped';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One open stream: events served until the edges need a fresh look
|
|
338
|
+
* (`refreshMs`), the stream ends or fails, or the watcher stops. Resolves
|
|
339
|
+
* to `{ token, end?, error? }`.
|
|
340
|
+
*/
|
|
341
|
+
async function serve(collection, serveSignal, context) {
|
|
342
|
+
const { edges, target, field } = context;
|
|
343
|
+
const follower = followers.get(collection);
|
|
344
|
+
const stream = deps.db.collection(collection).watch(watchPipeline(field, target), {
|
|
345
|
+
fullDocument: 'default',
|
|
346
|
+
maxAwaitTimeMS: 1000,
|
|
347
|
+
...(context.token !== undefined ? { resumeAfter: context.token } : {}),
|
|
348
|
+
});
|
|
349
|
+
let token = context.token;
|
|
350
|
+
let savedAt;
|
|
351
|
+
const jobs = new Map();
|
|
352
|
+
const refreshAt = Date.now() + settings.refreshMs;
|
|
353
|
+
const saveToken = async (force = false) => {
|
|
354
|
+
if (token === undefined || !tokenDue(savedAt, Date.now(), settings.checkpointMs, { force })) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const counters = follower?.pending ?? {};
|
|
358
|
+
if (follower) follower.pending = {};
|
|
359
|
+
const names = [];
|
|
360
|
+
for (const edge of edges.values()) names.push(edge.name);
|
|
361
|
+
await deps.watchStore.save(collection, owner, {
|
|
362
|
+
fields: {
|
|
363
|
+
resumeToken: token,
|
|
364
|
+
target,
|
|
365
|
+
edges: names,
|
|
366
|
+
...(follower?.lastEventAt ? { lastEventAt: follower.lastEventAt } : {}),
|
|
367
|
+
},
|
|
368
|
+
counters,
|
|
369
|
+
});
|
|
370
|
+
savedAt = Date.now();
|
|
371
|
+
};
|
|
372
|
+
// Lag is judged against the server's clock: a host running ahead would
|
|
373
|
+
// otherwise read every event as late and shed the collection each time.
|
|
374
|
+
const skew = await clockSkew(deps.db);
|
|
375
|
+
try {
|
|
376
|
+
// The first read opens the cursor: from here on the stream has every
|
|
377
|
+
// write. Only then is a fresh start's past probed — from "now" (no
|
|
378
|
+
// token), the probe covers what came before and the stream the rest,
|
|
379
|
+
// with no gap between them.
|
|
380
|
+
let held = await stream.tryNext();
|
|
381
|
+
if (context.token === undefined) {
|
|
382
|
+
await setState(collection, 'catching-up', { leading: true });
|
|
383
|
+
await verify(deps, { onDrift: deps.onDrift, collections: [collection] }).catch((error) =>
|
|
384
|
+
report(error, collection),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
await setState(collection, 'streaming', { leading: true });
|
|
388
|
+
while (!serveSignal.aborted) {
|
|
389
|
+
const event = held !== undefined ? held : await stream.tryNext();
|
|
390
|
+
held = undefined;
|
|
391
|
+
if (event === null) {
|
|
392
|
+
// Idle: the post-batch token keeps a filtered stream on the oplog.
|
|
393
|
+
if (stream.resumeToken) token = stream.resumeToken;
|
|
394
|
+
await saveToken();
|
|
395
|
+
if (Date.now() >= refreshAt) break;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (isEnding(event)) {
|
|
399
|
+
await deps.watchStore.save(collection, owner, { unset: ['resumeToken'] });
|
|
400
|
+
token = undefined;
|
|
401
|
+
await setState(collection, 'restarting', { leading: true });
|
|
402
|
+
return {
|
|
403
|
+
token: undefined,
|
|
404
|
+
end: event.operationType === 'invalidate' ? undefined : 'gone',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const lag = lagOf(event, Date.now() - skew);
|
|
408
|
+
if (lag !== undefined && lag > settings.maxLagMs) {
|
|
409
|
+
await shed(collection, edges);
|
|
410
|
+
token = undefined;
|
|
411
|
+
await deps.watchStore.save(collection, owner, { unset: ['resumeToken'] });
|
|
412
|
+
return { token: undefined };
|
|
413
|
+
}
|
|
414
|
+
await upgrade(collection, event, context, jobs, lag);
|
|
415
|
+
// Past this event only once it is served: a failure reopens before it.
|
|
416
|
+
token = event._id;
|
|
417
|
+
await saveToken();
|
|
418
|
+
}
|
|
419
|
+
return { token };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (serveSignal.aborted) return { token };
|
|
422
|
+
return { token, error };
|
|
423
|
+
} finally {
|
|
424
|
+
await stream.close().catch(() => undefined);
|
|
425
|
+
// On the way out, the position is kept whatever the cadence said.
|
|
426
|
+
await saveToken(true).catch(() => undefined);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Behind by more than maxLagMs: reopen the background migrations instead, start from now */
|
|
431
|
+
async function shed(collection, edges) {
|
|
432
|
+
await setState(collection, 'overloaded', { leading: true });
|
|
433
|
+
for (const edge of edges.values()) {
|
|
434
|
+
await control(deps, edge.name, 'retry', {
|
|
435
|
+
reason: 'the drift watcher fell behind',
|
|
436
|
+
}).catch((error) => report(error, collection));
|
|
437
|
+
}
|
|
438
|
+
deps.logger.warn(
|
|
439
|
+
`⚠ Drift watcher (${collection}) fell more than ${settings.maxLagMs}ms behind — reopened ` +
|
|
440
|
+
'its background migrations and started again from now',
|
|
441
|
+
deps.fields({ collection }),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Upgrade the one document an event names, edge by edge, up to the target shape */
|
|
446
|
+
async function upgrade(collection, event, context, jobs, lag) {
|
|
447
|
+
const { edges, byName, target, field } = context;
|
|
448
|
+
const follower = followers.get(collection);
|
|
449
|
+
const count = (key) => {
|
|
450
|
+
if (!follower) return;
|
|
451
|
+
follower.counters[key] += 1;
|
|
452
|
+
follower.pending ??= {};
|
|
453
|
+
follower.pending[key] = (follower.pending[key] ?? 0) + 1;
|
|
454
|
+
};
|
|
455
|
+
count('events');
|
|
456
|
+
if (follower) follower.lastEventAt = new Date();
|
|
457
|
+
const key = event.documentKey;
|
|
458
|
+
const coll = deps.db.collection(collection);
|
|
459
|
+
const below = belowVersionFilter({ field }, target);
|
|
460
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
461
|
+
const doc = await coll.findOne(below ? { $and: [key, below] } : key, READ_OPTIONS);
|
|
462
|
+
if (doc === null) {
|
|
463
|
+
if (hop === 0) count('skipped');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const edge = edges.get(versionOf(doc, field));
|
|
467
|
+
if (edge === undefined || !settings.upgrade) {
|
|
468
|
+
drift(collection, edge?.name ?? lastEdge(edges), 'reported');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
let job = jobs.get(edge.name);
|
|
472
|
+
if (job === undefined) {
|
|
473
|
+
try {
|
|
474
|
+
job = await jobFor(deps, edge.name, byName.get(edge.name));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
// Mid-deploy: another version of the file — the lanes take the document.
|
|
477
|
+
report(error, collection);
|
|
478
|
+
await handBack(collection, edge.name, 'the drift watcher could not load the migration');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
jobs.set(edge.name, job);
|
|
482
|
+
}
|
|
483
|
+
const fresh = await coll.findOne({ $and: [key, job.match] }, READ_OPTIONS);
|
|
484
|
+
if (fresh === null) return;
|
|
485
|
+
const result = await rewrite(job, fresh);
|
|
486
|
+
if (result === null) {
|
|
487
|
+
// Still in the way after every retry: in 'stream' mode no poll would
|
|
488
|
+
// look at this collection, so the lanes take what is left.
|
|
489
|
+
await handBack(collection, edge.name, 'the drift watcher kept losing to other writes');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (result.errors.length > 0) {
|
|
493
|
+
count('failed');
|
|
494
|
+
drift(collection, edge.name, 'failed');
|
|
495
|
+
if (deps.onDrift === 'reopen') {
|
|
496
|
+
await control(deps, edge.name, 'retry', {
|
|
497
|
+
reason: 'the drift watcher could not upgrade a document',
|
|
498
|
+
}).catch((error) => report(error, collection));
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (result.migrated !== 1) return;
|
|
503
|
+
count('upgraded');
|
|
504
|
+
drift(collection, edge.name, 'upgraded');
|
|
505
|
+
if (lag !== undefined) {
|
|
506
|
+
deps.telemetry?.backgroundWatchDelay({ name: edge.name, delayMs: lag });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* One document through the lanes' write path — in a transaction for a
|
|
513
|
+
* transactional background migration, so its side writes commit with it
|
|
514
|
+
* (and the driver retries a transient failure). A concurrent write that
|
|
515
|
+
* moves it first is read again and retried, up to `maxConflictRetries`
|
|
516
|
+
* times, as a lane would; `null` when it is still in the way after that.
|
|
517
|
+
*/
|
|
518
|
+
async function rewrite(job, doc) {
|
|
519
|
+
if (!job.spec.transaction) return applyBatch(job, [doc], { db: deps.db });
|
|
520
|
+
let current = doc;
|
|
521
|
+
for (let attempt = 0; ; attempt++) {
|
|
522
|
+
const session = deps.client.startSession();
|
|
523
|
+
try {
|
|
524
|
+
let result;
|
|
525
|
+
await session.withTransaction(async () => {
|
|
526
|
+
result = await applyBatch(job, [current], {
|
|
527
|
+
db: deps.db,
|
|
528
|
+
session,
|
|
529
|
+
ctxExtra: { session, db: deps.db, client: deps.client },
|
|
530
|
+
abortOnConflict: true,
|
|
531
|
+
strict: true,
|
|
532
|
+
});
|
|
533
|
+
}, transactionOptions(job.spec));
|
|
534
|
+
return result;
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (error?.context?.reason === 'document') {
|
|
537
|
+
return { migrated: 0, errors: [error.context.document] };
|
|
538
|
+
}
|
|
539
|
+
if (error?.context?.reason !== 'write-conflict') throw error;
|
|
540
|
+
if (attempt >= job.spec.maxConflictRetries) return null;
|
|
541
|
+
current = await deps.db
|
|
542
|
+
.collection(job.spec.collection)
|
|
543
|
+
.findOne({ $and: [{ _id: doc._id }, job.match] }, READ_OPTIONS);
|
|
544
|
+
// Upgraded, or no longer one to upgrade, by the write that won.
|
|
545
|
+
if (current === null) return { migrated: 0, errors: [] };
|
|
546
|
+
} finally {
|
|
547
|
+
await session.endSession().catch(() => undefined);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* What the watcher could not upgrade goes back to the lanes: with
|
|
554
|
+
* `onDrift: 'reopen'` the background migration is reopened (a pass over
|
|
555
|
+
* what is left), otherwise the drift is reported.
|
|
556
|
+
*/
|
|
557
|
+
async function handBack(collection, migration, reason) {
|
|
558
|
+
if (deps.onDrift !== 'reopen') {
|
|
559
|
+
drift(collection, migration, 'reported');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
drift(collection, migration, 'reopened');
|
|
563
|
+
await control(deps, migration, 'retry', { reason }).catch((error) => report(error, collection));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function drift(collection, migration, action) {
|
|
567
|
+
deps.telemetry?.backgroundDrift({ name: migration });
|
|
568
|
+
deps.emit('background:drift', { migration, collection, source: 'stream', action });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const work = supervise();
|
|
572
|
+
let stopping;
|
|
573
|
+
return {
|
|
574
|
+
get running() {
|
|
575
|
+
return !signal.aborted;
|
|
576
|
+
},
|
|
577
|
+
/** What each followed collection's watcher is doing in this process */
|
|
578
|
+
status() {
|
|
579
|
+
const rows = [];
|
|
580
|
+
for (const [collection, follower] of followers) {
|
|
581
|
+
rows.push({
|
|
582
|
+
collection,
|
|
583
|
+
state: follower.state ?? 'starting',
|
|
584
|
+
leading: follower.leading,
|
|
585
|
+
counters: { ...follower.counters },
|
|
586
|
+
...(follower.lastEventAt ? { lastEventAt: follower.lastEventAt } : {}),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return rows;
|
|
590
|
+
},
|
|
591
|
+
stop() {
|
|
592
|
+
stopping ??= (async () => {
|
|
593
|
+
if (!signal.aborted) controller.abort(stopReason('Drift watcher stopped'));
|
|
594
|
+
await work;
|
|
595
|
+
const done = [];
|
|
596
|
+
for (const follower of followers.values()) done.push(follower.done);
|
|
597
|
+
await Promise.allSettled(done);
|
|
598
|
+
options.signal?.removeEventListener('abort', onOuterAbort);
|
|
599
|
+
})();
|
|
600
|
+
return stopping;
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = { WATCH_DEFAULTS: DEFAULTS, startWatch, watchOptions };
|