@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,269 @@
|
|
|
1
|
+
const { errorText } = require('../utils/error.js');
|
|
2
|
+
const { isUnauthorized } = require('./shard-info.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pacing a background migration: it rewrites a production collection while
|
|
6
|
+
* the application uses it, so it must yield to it — before every batch:
|
|
7
|
+
*
|
|
8
|
+
* - the author's pause between batches (`pauseMs`);
|
|
9
|
+
* - the author's own `throttle(ctx)` hook (a returned number is extra pause);
|
|
10
|
+
* - replication lag: on a replica set, wait while a secondary that counts
|
|
11
|
+
* (not hidden, not delayed) lags more than `maxReplicationLagMs` — the
|
|
12
|
+
* status read at most every 5 s, and switched off quietly where it cannot
|
|
13
|
+
* be read (standalone, mongos) or with one warning where it may not be
|
|
14
|
+
* (no `clusterMonitor`).
|
|
15
|
+
*
|
|
16
|
+
* The latency-driven batch sizing (AIMD) lives here too.
|
|
17
|
+
* Clock and sleep are injected, so every rule is tested without waiting.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How often the replication status is read, at most */
|
|
21
|
+
const LAG_CHECK_INTERVAL_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** How often the replica set config (hidden and delayed members) is read, at most */
|
|
24
|
+
const CONFIG_CHECK_INTERVAL_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** How long to wait between two looks at a lag that is too high */
|
|
27
|
+
const LAG_WAIT_MS = 1_000;
|
|
28
|
+
|
|
29
|
+
/** Errors that mean "no replication status here" — not a replica set member */
|
|
30
|
+
const NO_REPLICATION = new Set([
|
|
31
|
+
59, // CommandNotFound (mongos)
|
|
32
|
+
76, // NoReplicationEnabled (standalone)
|
|
33
|
+
115, // CommandNotSupported
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** `ms`, jittered down to half of it — so processes that start together drift apart */
|
|
37
|
+
const jitter = (ms) => Math.round(ms * (0.5 + Math.random() / 2));
|
|
38
|
+
|
|
39
|
+
/** Sleep `ms`, cut short (rejecting with the reason) when `signal` aborts */
|
|
40
|
+
function sleep(ms, signal) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
reject(signal.reason);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (ms <= 0) {
|
|
47
|
+
resolve();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const onAbort = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
reject(signal.reason);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
signal?.removeEventListener('abort', onAbort);
|
|
56
|
+
resolve();
|
|
57
|
+
}, ms);
|
|
58
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The worst lag of a secondary that counts, in ms — or 0. Hidden and delayed
|
|
64
|
+
* members are left out: a delayed one lags on purpose, and a hidden one
|
|
65
|
+
* serves no reads.
|
|
66
|
+
*/
|
|
67
|
+
function replicationLagMs(status, excluded) {
|
|
68
|
+
const members = Array.isArray(status?.members) ? status.members : [];
|
|
69
|
+
let primary;
|
|
70
|
+
for (const member of members) {
|
|
71
|
+
if (member.stateStr === 'PRIMARY') primary = member;
|
|
72
|
+
}
|
|
73
|
+
if (primary?.optimeDate === undefined) return 0;
|
|
74
|
+
const primaryTime = new Date(primary.optimeDate).getTime();
|
|
75
|
+
let worst = 0;
|
|
76
|
+
for (const member of members) {
|
|
77
|
+
if (member.stateStr !== 'SECONDARY' || excluded.has(member.name)) continue;
|
|
78
|
+
if (member.optimeDate === undefined) continue;
|
|
79
|
+
const lag = primaryTime - new Date(member.optimeDate).getTime();
|
|
80
|
+
if (lag > worst) worst = lag;
|
|
81
|
+
}
|
|
82
|
+
return worst;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Hidden or delayed member hosts, from the replica set config */
|
|
86
|
+
function excludedMembers(config) {
|
|
87
|
+
const hosts = new Set();
|
|
88
|
+
for (const member of config?.config?.members ?? []) {
|
|
89
|
+
const delay = member.secondaryDelaySecs ?? member.slaveDelay ?? 0;
|
|
90
|
+
if (member.hidden === true || Number(delay) > 0) hosts.add(member.host);
|
|
91
|
+
}
|
|
92
|
+
return hosts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A throttle for one lane. `options`: `{ spec, db, logger, name, now?,
|
|
97
|
+
* sleep?, signal? }`. `beforeBatch(ctx)` waits as the rules say; it rejects
|
|
98
|
+
* only when `signal` aborts.
|
|
99
|
+
*/
|
|
100
|
+
function createThrottle({ spec, db, logger, name, now = Date.now, wait = sleep, warned }) {
|
|
101
|
+
const maxLag = spec.maxReplicationLagMs;
|
|
102
|
+
let lagEnabled = maxLag !== false && typeof db?.admin === 'function';
|
|
103
|
+
let lastLagCheck = -Infinity;
|
|
104
|
+
let lastConfigCheck = -Infinity;
|
|
105
|
+
let excluded = new Set();
|
|
106
|
+
let first = true;
|
|
107
|
+
// Set when the last wait was for a lagging secondary — an overload signal.
|
|
108
|
+
let lagged = false;
|
|
109
|
+
// One warning per process for a lag that cannot be read for lack of rights.
|
|
110
|
+
const warnings = warned ?? new Set();
|
|
111
|
+
|
|
112
|
+
async function currentLag() {
|
|
113
|
+
const admin = db.admin();
|
|
114
|
+
if (now() - lastConfigCheck >= CONFIG_CHECK_INTERVAL_MS) {
|
|
115
|
+
lastConfigCheck = now();
|
|
116
|
+
try {
|
|
117
|
+
excluded = excludedMembers(await admin.command({ replSetGetConfig: 1 }));
|
|
118
|
+
} catch {
|
|
119
|
+
// Without the config every secondary counts — the cautious reading.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return replicationLagMs(await admin.command({ replSetGetStatus: 1 }), excluded);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function waitForLag(signal) {
|
|
126
|
+
if (!lagEnabled || now() - lastLagCheck < LAG_CHECK_INTERVAL_MS) return;
|
|
127
|
+
for (;;) {
|
|
128
|
+
lastLagCheck = now();
|
|
129
|
+
let lag;
|
|
130
|
+
try {
|
|
131
|
+
lag = await currentLag();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
lagEnabled = false;
|
|
134
|
+
if (isUnauthorized(error) && !warnings.has('lag')) {
|
|
135
|
+
warnings.add('lag');
|
|
136
|
+
logger.warn(
|
|
137
|
+
'⚠ Background migrations cannot read the replication lag (replSetGetStatus needs ' +
|
|
138
|
+
'the clusterMonitor role) — they no longer wait for secondaries',
|
|
139
|
+
{ background: name, error: errorText(error) },
|
|
140
|
+
);
|
|
141
|
+
} else if (!NO_REPLICATION.has(error?.code)) {
|
|
142
|
+
logger.debug(`Replication lag unreadable — not waiting for it: ${errorText(error)}`, {
|
|
143
|
+
background: name,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (lag <= maxLag) return;
|
|
149
|
+
lagged = true;
|
|
150
|
+
logger.debug(`Replication lag ${lag}ms > ${maxLag}ms — waiting`, {
|
|
151
|
+
background: name,
|
|
152
|
+
lagMs: lag,
|
|
153
|
+
});
|
|
154
|
+
await wait(LAG_WAIT_MS, signal);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
/** Whether the last `beforeBatch` waited for a lagging secondary — read once */
|
|
160
|
+
takeLagged() {
|
|
161
|
+
const was = lagged;
|
|
162
|
+
lagged = false;
|
|
163
|
+
return was;
|
|
164
|
+
},
|
|
165
|
+
/** Everything a lane waits for before reading its next batch */
|
|
166
|
+
async beforeBatch({ signal, generation, partition, batchSize, extraPauseMs = 0 }) {
|
|
167
|
+
const pause = (first ? 0 : spec.pauseMs) + extraPauseMs;
|
|
168
|
+
if (pause > 0) await wait(pause, signal);
|
|
169
|
+
first = false;
|
|
170
|
+
if (typeof spec.throttle === 'function') {
|
|
171
|
+
const extra = await spec.throttle({
|
|
172
|
+
name,
|
|
173
|
+
...(spec.collection !== undefined ? { collection: spec.collection } : {}),
|
|
174
|
+
generation,
|
|
175
|
+
partition,
|
|
176
|
+
batchSize,
|
|
177
|
+
signal,
|
|
178
|
+
});
|
|
179
|
+
if (typeof extra === 'number' && extra > 0) await wait(extra, signal);
|
|
180
|
+
}
|
|
181
|
+
await waitForLag(signal);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Adaptive batch sizing (AIMD) ─────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** How often a throttle change is reported, at most, per controller */
|
|
189
|
+
const THROTTLE_REPORT_INTERVAL_MS = 10_000;
|
|
190
|
+
|
|
191
|
+
/** The first extra pause once the batch is as small as it gets */
|
|
192
|
+
const FIRST_BACKOFF_MS = 100;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A latency-driven batch size, additive-increase / multiplicative-decrease —
|
|
196
|
+
* TCP's answer to "how fast may I go without knowing the capacity": every
|
|
197
|
+
* batch written within `targetLatencyMs` grows the next one a little; one
|
|
198
|
+
* slower, or a sign of overload (a write concern timeout, a transient error,
|
|
199
|
+
* a lagging secondary), halves it — at most once per round trip, so one slow
|
|
200
|
+
* moment is not punished twice. At `minBatchSize` it backs off in time
|
|
201
|
+
* instead: an extra pause that doubles up to `maxPauseMs` and halves again as
|
|
202
|
+
* things recover. The first batch only warms up.
|
|
203
|
+
*
|
|
204
|
+
* Works on every topology — behind a mongos and on a standalone it is the
|
|
205
|
+
* only signal there is. `settings` is the resolved `spec.adaptive`;
|
|
206
|
+
* `initial` the state saved on the partition (`{ batchSize, pauseMs }`).
|
|
207
|
+
*/
|
|
208
|
+
function createAdaptive(settings, { initial, now = Date.now } = {}) {
|
|
209
|
+
const { targetLatencyMs, minBatchSize, maxBatchSize, maxPauseMs } = settings;
|
|
210
|
+
const increase = Math.max(1, Math.floor(maxBatchSize / 20));
|
|
211
|
+
let size = clamp(initial?.batchSize ?? maxBatchSize, minBatchSize, maxBatchSize);
|
|
212
|
+
let pause = clamp(initial?.pauseMs ?? 0, 0, maxPauseMs);
|
|
213
|
+
let warm = initial !== undefined;
|
|
214
|
+
let lastDecrease = -Infinity;
|
|
215
|
+
let lastReport = -Infinity;
|
|
216
|
+
let pendingReason;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
batchSize: () => size,
|
|
220
|
+
pauseMs: () => pause,
|
|
221
|
+
state: () => ({ batchSize: size, pauseMs: pause }),
|
|
222
|
+
/**
|
|
223
|
+
* Judge one batch: `{ latencyMs, overloaded? }`. Returns the change to
|
|
224
|
+
* report — `{ reason, batchSize, pauseMs }` with `reason` `'slow'`,
|
|
225
|
+
* `'overload'` or `'recover'` — at most every 10 s, else `undefined`.
|
|
226
|
+
*/
|
|
227
|
+
record({ latencyMs, overloaded = false }) {
|
|
228
|
+
if (!warm) {
|
|
229
|
+
warm = true;
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
const slow = latencyMs > targetLatencyMs;
|
|
233
|
+
let reason;
|
|
234
|
+
if (overloaded || slow) {
|
|
235
|
+
// Once per round trip: the batch that saw the last decrease does not count twice.
|
|
236
|
+
if (now() - lastDecrease < Math.max(latencyMs, 1)) return undefined;
|
|
237
|
+
lastDecrease = now();
|
|
238
|
+
reason = overloaded ? 'overload' : 'slow';
|
|
239
|
+
if (size > minBatchSize) size = Math.max(minBatchSize, Math.floor(size / 2));
|
|
240
|
+
else pause = Math.min(maxPauseMs, Math.max(FIRST_BACKOFF_MS, pause * 2));
|
|
241
|
+
} else if (pause > 0 || size < maxBatchSize) {
|
|
242
|
+
reason = 'recover';
|
|
243
|
+
if (pause > 0) pause = Math.floor(pause / 2);
|
|
244
|
+
else size = Math.min(maxBatchSize, size + increase);
|
|
245
|
+
}
|
|
246
|
+
if (reason === undefined) return undefined;
|
|
247
|
+
pendingReason =
|
|
248
|
+
pendingReason === 'recover' || pendingReason === undefined ? reason : pendingReason;
|
|
249
|
+
if (now() - lastReport < THROTTLE_REPORT_INTERVAL_MS) return undefined;
|
|
250
|
+
lastReport = now();
|
|
251
|
+
const report = { reason: pendingReason, batchSize: size, pauseMs: pause };
|
|
252
|
+
pendingReason = undefined;
|
|
253
|
+
return report;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
LAG_CHECK_INTERVAL_MS,
|
|
262
|
+
THROTTLE_REPORT_INTERVAL_MS,
|
|
263
|
+
createAdaptive,
|
|
264
|
+
createThrottle,
|
|
265
|
+
excludedMembers,
|
|
266
|
+
jitter,
|
|
267
|
+
replicationLagMs,
|
|
268
|
+
sleep,
|
|
269
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live drift watcher's decisions, kept pure: which background migrations
|
|
3
|
+
* a collection's watcher upgrades with, what its change stream asks the
|
|
4
|
+
* server for, how a stream error is read, and when a resume token is due.
|
|
5
|
+
* background-watch.js does the I/O.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Server errors that mean the stream cannot resume from where it was */
|
|
9
|
+
const HISTORY_LOST = new Set([
|
|
10
|
+
286, // ChangeStreamHistoryLost — the oplog moved past the token
|
|
11
|
+
280, // ChangeStreamFatalError
|
|
12
|
+
136, // CappedPositionLost
|
|
13
|
+
]);
|
|
14
|
+
/** Not authorized to open (or keep) a change stream there */
|
|
15
|
+
const UNAUTHORIZED = 13;
|
|
16
|
+
/** "The $changeStream stage is only supported on replica sets" — a standalone */
|
|
17
|
+
const UNSUPPORTED = 40573;
|
|
18
|
+
|
|
19
|
+
/** Events that end a stream for good: the collection is gone or renamed */
|
|
20
|
+
const ENDING = new Set(['drop', 'rename', 'dropDatabase', 'invalidate']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The background migrations a collection's watcher may upgrade with — its
|
|
24
|
+
* edges: completed, forward, declarative ones (a step migration cannot run
|
|
25
|
+
* on one document). Returns `{ edges, target }`: `edges` by the version they
|
|
26
|
+
* start from (the first registered wins a tie), `target` the highest
|
|
27
|
+
* version any reaches — the shape a document should have at least.
|
|
28
|
+
*/
|
|
29
|
+
function edgesOf(states, collection) {
|
|
30
|
+
const edges = new Map();
|
|
31
|
+
let target;
|
|
32
|
+
for (const state of states) {
|
|
33
|
+
const spec = state.spec;
|
|
34
|
+
if (spec?.collection !== collection || spec.mode !== 'declarative') continue;
|
|
35
|
+
if (state.status !== 'completed' || state.direction === 'revert') continue;
|
|
36
|
+
if (!edges.has(spec.from)) {
|
|
37
|
+
edges.set(spec.from, { name: state._id, from: spec.from, to: spec.to });
|
|
38
|
+
}
|
|
39
|
+
if (target === undefined || spec.to > target) target = spec.to;
|
|
40
|
+
}
|
|
41
|
+
return { edges, target };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Statuses after which a background migration does nothing more */
|
|
45
|
+
const SETTLED = new Set(['completed', 'failed', 'cancelled']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The revert that makes a collection's watcher stand aside, if any: one not
|
|
49
|
+
* settled yet — the watcher would upgrade straight back what it rewrites.
|
|
50
|
+
*/
|
|
51
|
+
function suspendedBy(states, collection) {
|
|
52
|
+
for (const state of states) {
|
|
53
|
+
if (state.spec?.collection !== collection || state.direction !== 'revert') continue;
|
|
54
|
+
if (!SETTLED.has(state.status)) return state._id;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The change stream's pipeline: only the writes that can leave a document
|
|
61
|
+
* below `target` — an insert or replace of an old shape, an update that sets
|
|
62
|
+
* the version field below it or to null, one that removes it — plus the
|
|
63
|
+
* events that end the stream. No `updateLookup`: the watcher reads the one
|
|
64
|
+
* document an event names, never one per update of the collection. An update
|
|
65
|
+
* that does not touch the version field matches nothing (`$exists` guards
|
|
66
|
+
* the null test, which would otherwise match every such update).
|
|
67
|
+
*/
|
|
68
|
+
function watchPipeline(field, target) {
|
|
69
|
+
const full = `fullDocument.${field}`;
|
|
70
|
+
const updated = `updateDescription.updatedFields.${field}`;
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
$match: {
|
|
74
|
+
$or: [
|
|
75
|
+
{
|
|
76
|
+
operationType: { $in: ['insert', 'replace'] },
|
|
77
|
+
$or: [{ [full]: { $lt: target } }, { [full]: { $exists: false } }, { [full]: null }],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
operationType: 'update',
|
|
81
|
+
$or: [
|
|
82
|
+
{ [updated]: { $lt: target } },
|
|
83
|
+
{ [updated]: { $exists: true, $type: 'null' } },
|
|
84
|
+
{ 'updateDescription.removedFields': field },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{ operationType: { $in: [...ENDING] } },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{ $project: { operationType: 1, documentKey: 1, clusterTime: 1 } },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How a stream error is met: `history-lost` (start over from now, after a
|
|
97
|
+
* drift probe of the collection), `unauthorized` (leave the collection to
|
|
98
|
+
* the polling watch), `unsupported` (no change streams here at all), or
|
|
99
|
+
* `retry` (reopen from the last token, after a backoff).
|
|
100
|
+
*/
|
|
101
|
+
function classifyStreamError(error) {
|
|
102
|
+
const code = error?.code;
|
|
103
|
+
if (HISTORY_LOST.has(code)) return 'history-lost';
|
|
104
|
+
if (code === UNAUTHORIZED) return 'unauthorized';
|
|
105
|
+
if (code === UNSUPPORTED) return 'unsupported';
|
|
106
|
+
if (error?.hasErrorLabel?.('NonResumableChangeStreamError')) return 'history-lost';
|
|
107
|
+
return 'retry';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether an event ends the stream (the collection dropped or renamed) */
|
|
111
|
+
const isEnding = (event) => ENDING.has(event?.operationType);
|
|
112
|
+
|
|
113
|
+
/** Milliseconds since the event happened — the stream's lag — or `undefined` without a time */
|
|
114
|
+
function lagOf(event, now) {
|
|
115
|
+
const time = event?.clusterTime;
|
|
116
|
+
if (time === undefined || time === null) return undefined;
|
|
117
|
+
// A BSON Timestamp: seconds in `t` (the high 32 bits), an ordinal in `i`.
|
|
118
|
+
const seconds = time.t;
|
|
119
|
+
return typeof seconds === 'number' ? Math.max(0, now - seconds * 1000) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether a resume token is due: at most every `checkpointMs`, and always when told to */
|
|
123
|
+
function tokenDue(lastSavedAt, now, checkpointMs, { force = false } = {}) {
|
|
124
|
+
return force || lastSavedAt === undefined || now - lastSavedAt >= checkpointMs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A leader's view of its collection, in one pass over the states: the revert
|
|
129
|
+
* it stands aside for (`suspended`), its `edges` and `target` (as
|
|
130
|
+
* {@link edgesOf}), and every state by name.
|
|
131
|
+
*/
|
|
132
|
+
function watchView(states, collection) {
|
|
133
|
+
const edges = new Map();
|
|
134
|
+
const byName = new Map();
|
|
135
|
+
let target;
|
|
136
|
+
let suspended;
|
|
137
|
+
for (const state of states) {
|
|
138
|
+
byName.set(state._id, state);
|
|
139
|
+
const spec = state.spec;
|
|
140
|
+
if (spec?.collection !== collection) continue;
|
|
141
|
+
if (state.direction === 'revert') {
|
|
142
|
+
if (suspended === undefined && !SETTLED.has(state.status)) suspended = state._id;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (spec.mode !== 'declarative' || state.status !== 'completed') continue;
|
|
146
|
+
if (!edges.has(spec.from)) {
|
|
147
|
+
edges.set(spec.from, { name: state._id, from: spec.from, to: spec.to });
|
|
148
|
+
}
|
|
149
|
+
if (target === undefined || spec.to > target) target = spec.to;
|
|
150
|
+
}
|
|
151
|
+
return { suspended, edges, target, byName };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
HISTORY_LOST,
|
|
156
|
+
classifyStreamError,
|
|
157
|
+
edgesOf,
|
|
158
|
+
isEnding,
|
|
159
|
+
lagOf,
|
|
160
|
+
suspendedBy,
|
|
161
|
+
tokenDue,
|
|
162
|
+
watchPipeline,
|
|
163
|
+
watchView,
|
|
164
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const { backgroundCollectionNames } = require('./config.js');
|
|
3
|
+
const { READ_OPTIONS } = require('./server-info.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the live drift watcher keeps what outlives a process — one document
|
|
7
|
+
* per watched collection in `<backgroundCollection>_watch`:
|
|
8
|
+
* `{ _id: collection, resumeToken?, target, edges, state, leader,
|
|
9
|
+
* counters, lastEventAt, lastError, updatedAt }`.
|
|
10
|
+
*
|
|
11
|
+
* Only the leader writes, and every write is fenced by `leader.owner`: a
|
|
12
|
+
* watcher that lost its lock (and so its leadership) without knowing it
|
|
13
|
+
* cannot move the token back. The token never leaves this module's status
|
|
14
|
+
* reads — it is the stream's position, of no use to anyone else.
|
|
15
|
+
*/
|
|
16
|
+
class BackgroundWatchStore {
|
|
17
|
+
#collection;
|
|
18
|
+
|
|
19
|
+
constructor(db, backgroundCollection) {
|
|
20
|
+
this.#collection = db.collection(backgroundCollectionNames(backgroundCollection).watch);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The stored document of a collection's watcher, token included, or null */
|
|
24
|
+
async get(collection) {
|
|
25
|
+
return this.#collection.findOne({ _id: collection }, READ_OPTIONS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Become the writer of a collection's document — called while holding its watcher lock */
|
|
29
|
+
async lead(collection, owner) {
|
|
30
|
+
await this.#collection.updateOne(
|
|
31
|
+
{ _id: collection },
|
|
32
|
+
{
|
|
33
|
+
$set: {
|
|
34
|
+
leader: { owner, host: os.hostname(), pid: process.pid, at: new Date() },
|
|
35
|
+
updatedAt: new Date(),
|
|
36
|
+
},
|
|
37
|
+
$setOnInsert: { counters: {} },
|
|
38
|
+
},
|
|
39
|
+
{ upsert: true },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Save what the leader knows: `fields` set (a `resumeToken`, a `state`, …),
|
|
45
|
+
* `counters` added, `unset` removed. Resolves whether `owner` still leads.
|
|
46
|
+
*/
|
|
47
|
+
async save(collection, owner, { fields = {}, counters = {}, unset = [] } = {}) {
|
|
48
|
+
const update = { $set: { ...fields, updatedAt: new Date() } };
|
|
49
|
+
const inc = {};
|
|
50
|
+
for (const [key, value] of Object.entries(counters)) {
|
|
51
|
+
if (value !== 0) inc[`counters.${key}`] = value;
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(inc).length > 0) update.$inc = inc;
|
|
54
|
+
if (unset.length > 0) {
|
|
55
|
+
update.$unset = {};
|
|
56
|
+
for (const key of unset) update.$unset[key] = '';
|
|
57
|
+
}
|
|
58
|
+
const result = await this.#collection.updateOne(
|
|
59
|
+
{ _id: collection, 'leader.owner': owner },
|
|
60
|
+
update,
|
|
61
|
+
);
|
|
62
|
+
return result.matchedCount === 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every watched collection's document — or one — without its resume token */
|
|
66
|
+
async status(collection) {
|
|
67
|
+
const projection = { resumeToken: 0 };
|
|
68
|
+
if (collection !== undefined) {
|
|
69
|
+
return this.#collection.findOne({ _id: collection }, { projection, ...READ_OPTIONS });
|
|
70
|
+
}
|
|
71
|
+
return this.#collection
|
|
72
|
+
.find({}, { projection, ...READ_OPTIONS })
|
|
73
|
+
.sort({ _id: 1 })
|
|
74
|
+
.toArray();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { BackgroundWatchStore };
|