@alexify/migronaut 2.1.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 +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- 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 +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- 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/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- 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 +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- 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 +125 -1
- package/src/utils/template.js +69 -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,818 @@
|
|
|
1
|
+
const {
|
|
2
|
+
BackgroundConflictError,
|
|
3
|
+
BackgroundFailedError,
|
|
4
|
+
LockLostError,
|
|
5
|
+
} = require('../errors/index.js');
|
|
6
|
+
const { canonical, isPlainObject } = require('../utils/canonical.js');
|
|
7
|
+
const { documentErrorText } = require('../utils/error.js');
|
|
8
|
+
const {
|
|
9
|
+
cloneDocument,
|
|
10
|
+
occFilter,
|
|
11
|
+
revisionOf,
|
|
12
|
+
stampedDiff,
|
|
13
|
+
versionFilter,
|
|
14
|
+
versionOf,
|
|
15
|
+
} = require('../versioning/document.js');
|
|
16
|
+
const { MAX_CHECKPOINT_BYTES } = require('./background-spec.js');
|
|
17
|
+
const { bsonSize } = require('./bson-peer.js');
|
|
18
|
+
const { sleep } = require('./background-throttle.js');
|
|
19
|
+
const { READ_OPTIONS } = require('./server-info.js');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The batch engine: one background migration's documents rewritten, batch
|
|
23
|
+
* by batch, inside one partition a lane holds the lease of.
|
|
24
|
+
*
|
|
25
|
+
* A batch is read through the version index, transformed (on copies), and
|
|
26
|
+
* written as one unordered `bulkWrite` of operator updates — each guarded by
|
|
27
|
+
* the document's `_id`, its exact old version and (with revisions) its exact
|
|
28
|
+
* revision. A document the application wrote in between matches nothing:
|
|
29
|
+
* it is read again and transformed again, up to `maxConflictRetries` rounds,
|
|
30
|
+
* and whatever is still in the way is left for the next pass. Only the fields
|
|
31
|
+
* a transformation changed are written (`stampedDiff`), so every other field
|
|
32
|
+
* keeps its stored BSON type. After each batch the partition's cursor moves
|
|
33
|
+
* forward in one fenced checkpoint.
|
|
34
|
+
*
|
|
35
|
+
* `applyBatch` is the one write path: the slice engine, the live drift
|
|
36
|
+
* watcher and the dry-run sandbox all go through it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Server errors that fail just the document, not the batch */
|
|
40
|
+
const DOCUMENT_ERRORS = new Set([
|
|
41
|
+
11000, // duplicate key
|
|
42
|
+
121, // document failed validation
|
|
43
|
+
72, // invalid options — a shard key change through mongos
|
|
44
|
+
66, // immutable field
|
|
45
|
+
17280, // key too large to index
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** A stable string for an `_id`, whatever BSON type it is — a Map key */
|
|
49
|
+
const idKey = (id) => JSON.stringify(canonical(id));
|
|
50
|
+
|
|
51
|
+
/** What one direction of a background migration reads, writes and calls */
|
|
52
|
+
function directionOf(spec, fns, direction) {
|
|
53
|
+
const forward = direction !== 'revert';
|
|
54
|
+
return {
|
|
55
|
+
source: forward ? spec.from : spec.to,
|
|
56
|
+
target: forward ? spec.to : spec.from,
|
|
57
|
+
one: forward ? fns.migrate : fns.revert,
|
|
58
|
+
many: forward ? fns.migrateBatch : fns.revertBatch,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The documents a background migration (still) has to rewrite, in one direction */
|
|
63
|
+
function matchOf(spec, direction) {
|
|
64
|
+
const source = direction === 'revert' ? spec.to : spec.from;
|
|
65
|
+
return { ...spec.filter, ...versionFilter(spec, source) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `match` without the documents that failed — left out of later passes and of the final count */
|
|
69
|
+
function excludeBadIds(match, badIds = []) {
|
|
70
|
+
return badIds.length > 0 ? { $and: [match, { _id: { $nin: badIds } }] } : match;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a transformation sees besides the document */
|
|
74
|
+
function transformContext(job, extra = {}) {
|
|
75
|
+
return {
|
|
76
|
+
signal: job.signal,
|
|
77
|
+
logger: job.logger,
|
|
78
|
+
direction: job.direction ?? 'forward',
|
|
79
|
+
background: {
|
|
80
|
+
name: job.name,
|
|
81
|
+
generation: job.generation,
|
|
82
|
+
partition: String(job.partitionId ?? ''),
|
|
83
|
+
},
|
|
84
|
+
...extra,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const docError = (doc, error, reason) => ({
|
|
89
|
+
id: doc._id,
|
|
90
|
+
error: documentErrorText(error),
|
|
91
|
+
...(reason !== undefined ? { reason } : {}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Transform `docs` (each on a copy): `[{ doc, next } | { doc, error }]`,
|
|
96
|
+
* aligned. A batch transformation that throws, or returns the wrong number
|
|
97
|
+
* of results, fails the slice — it cannot say which document was at fault.
|
|
98
|
+
*/
|
|
99
|
+
async function transformAll(job, docs, ctx) {
|
|
100
|
+
const { one, many } = directionOf(job.spec, job.fns, job.direction);
|
|
101
|
+
const results = new Array(docs.length);
|
|
102
|
+
if (typeof many === 'function') {
|
|
103
|
+
const copies = docs.map((doc) => cloneDocument(doc));
|
|
104
|
+
const out = await many(copies, ctx);
|
|
105
|
+
if (!Array.isArray(out) || out.length !== docs.length) {
|
|
106
|
+
throw new BackgroundFailedError(
|
|
107
|
+
`migrateBatch returned ${Array.isArray(out) ? out.length : 'no'} result(s) for ` +
|
|
108
|
+
`${docs.length} document(s) — it must return one per document, in order`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
for (let i = 0; i < docs.length; i++) {
|
|
112
|
+
results[i] =
|
|
113
|
+
out[i] instanceof Error ? { doc: docs[i], error: out[i] } : { doc: docs[i], next: out[i] };
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < docs.length; i++) {
|
|
118
|
+
try {
|
|
119
|
+
results[i] = { doc: docs[i], next: await one(cloneDocument(docs[i]), ctx) };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// A side write that hit a transient transaction error is the batch's,
|
|
122
|
+
// not the document's: the whole transaction is retried.
|
|
123
|
+
if (isTransientTransaction(error)) throw error;
|
|
124
|
+
results[i] = { doc: docs[i], error };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The guarded operations for transformed documents: `{ ops, owners, errors }`
|
|
132
|
+
* — `owners[i]` the document behind `ops[i]`.
|
|
133
|
+
*/
|
|
134
|
+
function buildOps(job, transformed) {
|
|
135
|
+
const { source, target } = directionOf(job.spec, job.fns, job.direction);
|
|
136
|
+
const partitioner = job.partitioner;
|
|
137
|
+
const ops = [];
|
|
138
|
+
const owners = [];
|
|
139
|
+
const errors = [];
|
|
140
|
+
for (const { doc, next, error } of transformed) {
|
|
141
|
+
if (error !== undefined) {
|
|
142
|
+
errors.push(docError(doc, error, 'transform'));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const refused = partitioner?.checkTransform?.(doc, next) ?? null;
|
|
146
|
+
if (refused) {
|
|
147
|
+
errors.push(docError(doc, refused.message ?? refused.reason, refused.reason));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let update;
|
|
151
|
+
try {
|
|
152
|
+
update = stampedDiff(doc, next, job.spec, { to: target });
|
|
153
|
+
} catch (stampError) {
|
|
154
|
+
errors.push(docError(doc, stampError, 'shape'));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
ops.push({
|
|
158
|
+
updateOne: {
|
|
159
|
+
filter: {
|
|
160
|
+
...occFilter(doc, job.spec, { from: source }),
|
|
161
|
+
...(partitioner?.writeFilter?.(doc) ?? {}),
|
|
162
|
+
},
|
|
163
|
+
update,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
owners.push(doc);
|
|
167
|
+
}
|
|
168
|
+
return { ops, owners, errors };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Write `ops`: `{ matched, failed: Map<index, writeError> }`. Only errors of
|
|
173
|
+
* single documents (a duplicate key, the validator) are taken apart; a write
|
|
174
|
+
* concern error, or anything else, fails the batch — and the slice.
|
|
175
|
+
*/
|
|
176
|
+
async function writeOps(collection, ops, { writeConcern, session, bare = false }) {
|
|
177
|
+
const failed = new Map();
|
|
178
|
+
if (ops.length === 0) return { matched: 0, failed };
|
|
179
|
+
try {
|
|
180
|
+
// Inside a transaction the transaction's write concern is the one that
|
|
181
|
+
// counts; in the sandbox (`bare`) the proxy supplies the session.
|
|
182
|
+
const result = await collection.bulkWrite(ops, {
|
|
183
|
+
ordered: false,
|
|
184
|
+
...(bare ? {} : session ? { session } : { writeConcern }),
|
|
185
|
+
});
|
|
186
|
+
return { matched: result.matchedCount, failed };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const raw = error?.writeErrors;
|
|
189
|
+
const writeErrors = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
190
|
+
if (writeErrors.length === 0 || error.err !== undefined) throw error;
|
|
191
|
+
if (error.result?.getWriteConcernError?.()) throw error;
|
|
192
|
+
for (const writeError of writeErrors) {
|
|
193
|
+
if (!DOCUMENT_ERRORS.has(writeError.code)) throw error;
|
|
194
|
+
failed.set(writeError.index, writeError);
|
|
195
|
+
}
|
|
196
|
+
return { matched: error.result?.matchedCount ?? 0, failed };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A filter for these documents: by `_id` — and, on a sharded collection,
|
|
202
|
+
* each with its shard key (an `$or` of the write filters), so the mongos
|
|
203
|
+
* reads them from their own shards instead of asking every one.
|
|
204
|
+
*/
|
|
205
|
+
function byIds(docs, partitioner) {
|
|
206
|
+
const ids = [];
|
|
207
|
+
const branches = [];
|
|
208
|
+
let keyed = false;
|
|
209
|
+
for (const doc of docs) {
|
|
210
|
+
ids.push(doc._id);
|
|
211
|
+
const key = partitioner?.writeFilter?.(doc) ?? {};
|
|
212
|
+
if (Object.keys(key).length > 0) keyed = true;
|
|
213
|
+
branches.push({ _id: doc._id, ...key });
|
|
214
|
+
}
|
|
215
|
+
return keyed ? { $or: branches } : { _id: { $in: ids } };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Transform and write one batch, retrying documents a concurrent write moved
|
|
220
|
+
* under it. Returns `{ migrated, skipped, conflicts, retried, errors }` —
|
|
221
|
+
* `errors` the documents that failed (`{ id, error, reason }`), `conflicts`
|
|
222
|
+
* the ones still in the way after every retry (the next pass takes them).
|
|
223
|
+
*
|
|
224
|
+
* `options`: `{ db, session?, writeConcern?, signal? }`; with a `session`
|
|
225
|
+
* every write joins it (the transactional mode, the sandbox) and a document
|
|
226
|
+
* a concurrent write moved aborts the batch instead (`abortOnConflict`).
|
|
227
|
+
*/
|
|
228
|
+
async function applyBatch(
|
|
229
|
+
job,
|
|
230
|
+
docs,
|
|
231
|
+
{ db, session, ctxExtra, abortOnConflict = false, strict = false, bare = false } = {},
|
|
232
|
+
) {
|
|
233
|
+
const spec = job.spec;
|
|
234
|
+
const collection = db.collection(spec.collection);
|
|
235
|
+
const { source, target } = directionOf(spec, job.fns, job.direction);
|
|
236
|
+
// `left`: documents read and not rewritten — a draining partition steps over them.
|
|
237
|
+
const counts = { migrated: 0, skipped: 0, conflicts: 0, retried: 0, errors: [], left: [] };
|
|
238
|
+
const ctx = transformContext(job, ctxExtra);
|
|
239
|
+
let pending = docs;
|
|
240
|
+
for (let round = 0; pending.length > 0; round++) {
|
|
241
|
+
const transformed = await transformAll(job, pending, ctx);
|
|
242
|
+
const { ops, owners, errors } = buildOps(job, transformed);
|
|
243
|
+
// Strict (a transaction): one bad document aborts the batch, to be retried
|
|
244
|
+
// without it — its side writes must not commit with the others.
|
|
245
|
+
if (strict && errors.length > 0) throw documentError(errors[0]);
|
|
246
|
+
counts.errors.push(...errors);
|
|
247
|
+
const { matched, failed } = await writeOps(collection, ops, {
|
|
248
|
+
writeConcern: spec.writeConcern,
|
|
249
|
+
session,
|
|
250
|
+
bare,
|
|
251
|
+
});
|
|
252
|
+
if (strict && failed.size > 0) {
|
|
253
|
+
const [[index, writeError]] = failed;
|
|
254
|
+
throw documentError(
|
|
255
|
+
docError(owners[index], writeError.errmsg ?? writeError.message, 'write'),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
for (const [index, writeError] of failed) {
|
|
259
|
+
counts.errors.push(docError(owners[index], writeError.errmsg ?? writeError.message, 'write'));
|
|
260
|
+
}
|
|
261
|
+
const written = ops.length - failed.size;
|
|
262
|
+
if (matched === written) {
|
|
263
|
+
counts.migrated += written;
|
|
264
|
+
return counts;
|
|
265
|
+
}
|
|
266
|
+
if (abortOnConflict) {
|
|
267
|
+
throw new BackgroundConflictError('A document changed under the transactional batch', {
|
|
268
|
+
reason: 'write-conflict',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// Some writes matched nothing: see what those documents are now.
|
|
272
|
+
const candidates = [];
|
|
273
|
+
for (let i = 0; i < owners.length; i++) if (!failed.has(i)) candidates.push(owners[i]);
|
|
274
|
+
const readOptions = { ...READ_OPTIONS, ...(session ? { session } : {}) };
|
|
275
|
+
const projection = {
|
|
276
|
+
[spec.field]: 1,
|
|
277
|
+
...(spec.revisionField ? { [spec.revisionField]: 1 } : {}),
|
|
278
|
+
};
|
|
279
|
+
const current = await collection
|
|
280
|
+
.find(byIds(candidates, job.partitioner), { projection, ...readOptions })
|
|
281
|
+
.toArray();
|
|
282
|
+
const byId = new Map();
|
|
283
|
+
for (const doc of current) byId.set(idKey(doc._id), doc);
|
|
284
|
+
const stale = [];
|
|
285
|
+
for (const prev of candidates) {
|
|
286
|
+
const now = byId.get(idKey(prev._id));
|
|
287
|
+
if (now === undefined) {
|
|
288
|
+
counts.skipped += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const version = versionOf(now, spec.field);
|
|
292
|
+
if (version === target) {
|
|
293
|
+
const expected = (revisionOf(prev, spec.revisionField ?? undefined) ?? 0) + 1;
|
|
294
|
+
if (spec.revisionField === null || revisionOf(now, spec.revisionField) === expected) {
|
|
295
|
+
counts.migrated += 1;
|
|
296
|
+
} else {
|
|
297
|
+
// Rewritten by us and touched again since, or upgraded by someone else.
|
|
298
|
+
counts.skipped += 1;
|
|
299
|
+
}
|
|
300
|
+
} else if (version === source) {
|
|
301
|
+
stale.push(prev);
|
|
302
|
+
} else {
|
|
303
|
+
counts.skipped += 1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (stale.length === 0) return counts;
|
|
307
|
+
// Still the old shape: read them whole — and still matching the filter,
|
|
308
|
+
// which the server judges.
|
|
309
|
+
const retry = await collection
|
|
310
|
+
.find(
|
|
311
|
+
{ $and: [job.match ?? matchOf(spec, job.direction), byIds(stale, job.partitioner)] },
|
|
312
|
+
readOptions,
|
|
313
|
+
)
|
|
314
|
+
.toArray();
|
|
315
|
+
counts.skipped += stale.length - retry.length;
|
|
316
|
+
if (retry.length === 0) return counts;
|
|
317
|
+
if (round >= spec.maxConflictRetries) {
|
|
318
|
+
counts.conflicts += retry.length;
|
|
319
|
+
for (const doc of retry) counts.left.push(doc._id);
|
|
320
|
+
return counts;
|
|
321
|
+
}
|
|
322
|
+
counts.retried += retry.length;
|
|
323
|
+
pending = retry;
|
|
324
|
+
}
|
|
325
|
+
return counts;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A document that fails a strict (transactional) batch — caught by transactionalBatch */
|
|
329
|
+
function documentError(entry) {
|
|
330
|
+
return new BackgroundFailedError(`A document failed: ${entry.error}`, {
|
|
331
|
+
reason: 'document',
|
|
332
|
+
document: entry,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The context of a `step` background migration: the database and client
|
|
338
|
+
* (and, in a transaction or a dry run, the session), its last checkpoint,
|
|
339
|
+
* and the deadline it should return by.
|
|
340
|
+
*/
|
|
341
|
+
function buildStepContext(job, { db, client, session, checkpoint, deadline, dryRun } = {}) {
|
|
342
|
+
return {
|
|
343
|
+
...transformContext(job),
|
|
344
|
+
db,
|
|
345
|
+
client,
|
|
346
|
+
checkpoint: checkpoint ?? null,
|
|
347
|
+
deadline,
|
|
348
|
+
...(session ? { session } : {}),
|
|
349
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A step's result, validated: `{ checkpoint, done, processed?, migrated?, total? }` */
|
|
354
|
+
function readStepResult(result) {
|
|
355
|
+
if (!isPlainObject(result) || typeof result.done !== 'boolean') {
|
|
356
|
+
throw new BackgroundFailedError('step() must return { checkpoint, done } — done a boolean');
|
|
357
|
+
}
|
|
358
|
+
const checkpoint = result.checkpoint ?? null;
|
|
359
|
+
if (bsonSize({ checkpoint }) > MAX_CHECKPOINT_BYTES) {
|
|
360
|
+
throw new BackgroundFailedError(
|
|
361
|
+
`step() returned a checkpoint larger than ${MAX_CHECKPOINT_BYTES / 1024} KiB — keep a cursor in it, not data`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const counters = {};
|
|
365
|
+
for (const key of ['processed', 'migrated']) {
|
|
366
|
+
if (Number.isSafeInteger(result[key]) && result[key] > 0) counters[key] = result[key];
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
checkpoint,
|
|
370
|
+
done: result.done,
|
|
371
|
+
counters,
|
|
372
|
+
...(Number.isSafeInteger(result.total) ? { total: result.total } : {}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* How every transaction of a background migration starts: a snapshot read
|
|
378
|
+
* on the primary, the spec's write concern (majority by default), and the
|
|
379
|
+
* spec's time limit on the commit.
|
|
380
|
+
*/
|
|
381
|
+
function transactionOptions(spec) {
|
|
382
|
+
return {
|
|
383
|
+
readConcern: { level: 'snapshot' },
|
|
384
|
+
writeConcern: spec.writeConcern ?? { w: 'majority' },
|
|
385
|
+
readPreference: 'primary',
|
|
386
|
+
...(spec.transaction?.timeoutMs !== undefined
|
|
387
|
+
? { maxCommitTimeMS: spec.transaction.timeoutMs }
|
|
388
|
+
: {}),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A write that failed for want of capacity, not for a fault of the data */
|
|
393
|
+
function isOverload(error) {
|
|
394
|
+
if (error?.err !== undefined || error?.writeConcernError !== undefined) return true;
|
|
395
|
+
if (error?.result?.getWriteConcernError?.()) return true;
|
|
396
|
+
if (error?.hasErrorLabel?.('TransientTransactionError') === true) return true;
|
|
397
|
+
if (error?.hasErrorLabel?.('RetryableWriteError') === true) return true;
|
|
398
|
+
// MaxTimeMSExpired, ExceededTimeLimit.
|
|
399
|
+
return error?.code === 50 || error?.code === 262;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Elapsed milliseconds from `start`, by the injected clock */
|
|
403
|
+
const since = (now, start) => now() - start;
|
|
404
|
+
|
|
405
|
+
/** Add `from` into `into`, key by key */
|
|
406
|
+
function addCounters(into, from) {
|
|
407
|
+
for (const [key, value] of Object.entries(from)) {
|
|
408
|
+
if (typeof value === 'number') into[key] = (into[key] ?? 0) + value;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* One call of a step migration and its checkpoint — in one transaction when
|
|
414
|
+
* the spec asks for it (the step's writes take `ctx.session` and commit with
|
|
415
|
+
* the checkpoint; a transient error runs the step again, up to `maxRetries`).
|
|
416
|
+
*/
|
|
417
|
+
async function runStep(job, ctx, cursor) {
|
|
418
|
+
const { store, lease, db, client, signal } = ctx;
|
|
419
|
+
const fn = job.direction === 'revert' ? job.fns.revertStep : job.fns.step;
|
|
420
|
+
const save = async (result, session) => {
|
|
421
|
+
const next = result.done ? null : { checkpoint: result.checkpoint };
|
|
422
|
+
const counts = { ...result.counters, batches: 1 };
|
|
423
|
+
await store.checkpoint(
|
|
424
|
+
lease,
|
|
425
|
+
{ cursor: next ?? { checkpoint: result.checkpoint }, counters: counts, done: result.done },
|
|
426
|
+
session ? { session } : {},
|
|
427
|
+
);
|
|
428
|
+
return { next, counts };
|
|
429
|
+
};
|
|
430
|
+
const stepContext = (session) =>
|
|
431
|
+
buildStepContext(job, {
|
|
432
|
+
db,
|
|
433
|
+
client,
|
|
434
|
+
checkpoint: cursor.checkpoint,
|
|
435
|
+
deadline: ctx.deadline,
|
|
436
|
+
...(session ? { session } : {}),
|
|
437
|
+
});
|
|
438
|
+
if (!job.spec.transaction) return save(readStepResult(await fn(stepContext())));
|
|
439
|
+
for (let attempt = 0; ; attempt++) {
|
|
440
|
+
const session = client.startSession();
|
|
441
|
+
try {
|
|
442
|
+
session.startTransaction(transactionOptions(job.spec));
|
|
443
|
+
const saved = await save(readStepResult(await fn(stepContext(session))), session);
|
|
444
|
+
await commit(session);
|
|
445
|
+
lease.touch();
|
|
446
|
+
return saved;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
await session.abortTransaction().catch(() => undefined);
|
|
449
|
+
if (!isTransientTransaction(error) || attempt >= job.spec.transaction.maxRetries) throw error;
|
|
450
|
+
await sleep(backoffMs(attempt + 1), signal);
|
|
451
|
+
} finally {
|
|
452
|
+
await session.endSession().catch(() => undefined);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The counters of one written batch */
|
|
458
|
+
function countsOf(docs, result, extra = {}) {
|
|
459
|
+
return {
|
|
460
|
+
scanned: docs.length,
|
|
461
|
+
migrated: result.migrated,
|
|
462
|
+
skipped: result.skipped,
|
|
463
|
+
conflicts: result.conflicts,
|
|
464
|
+
retried: result.retried,
|
|
465
|
+
failed: result.errors.length,
|
|
466
|
+
batches: 1,
|
|
467
|
+
...extra,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A batch's failed documents, in one pass: their ids (`badIds`, and on the
|
|
473
|
+
* `left` list a draining partition steps over) and their error entries.
|
|
474
|
+
*/
|
|
475
|
+
function errorsOf(errors, left = []) {
|
|
476
|
+
const badIds = [];
|
|
477
|
+
const docErrors = [];
|
|
478
|
+
const at = new Date();
|
|
479
|
+
for (const { id, error, reason } of errors) {
|
|
480
|
+
badIds.push(id);
|
|
481
|
+
left.push(id);
|
|
482
|
+
docErrors.push({ error, reason, at });
|
|
483
|
+
}
|
|
484
|
+
return { badIds, docErrors, left };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* One batch without a transaction: read, transform, write, then the fenced
|
|
489
|
+
* checkpoint — a crash between the writes and the checkpoint replays the
|
|
490
|
+
* batch, which the version filter makes a no-op. The adaptive throttle judges
|
|
491
|
+
* the write.
|
|
492
|
+
*/
|
|
493
|
+
async function plainBatch(job, ctx, cursor, batchSize) {
|
|
494
|
+
const { store, lease, partition, db, throttle } = ctx;
|
|
495
|
+
const now = ctx.now ?? Date.now;
|
|
496
|
+
const adaptive = ctx.adaptive;
|
|
497
|
+
const query = job.partitioner.batchQuery(partition.scope, cursor, {
|
|
498
|
+
limit: batchSize,
|
|
499
|
+
match: job.match,
|
|
500
|
+
hint: job.hint,
|
|
501
|
+
});
|
|
502
|
+
const docs = await db.collection(job.spec.collection).find(query.filter, query.options).toArray();
|
|
503
|
+
const writeStarted = now();
|
|
504
|
+
let result;
|
|
505
|
+
try {
|
|
506
|
+
result = await applyBatch(job, docs, { db });
|
|
507
|
+
} catch (error) {
|
|
508
|
+
// A write concern timeout or a transient error is overload — back off before failing.
|
|
509
|
+
if (isOverload(error)) {
|
|
510
|
+
adaptive?.record({ latencyMs: since(now, writeStarted), overloaded: true });
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
const change = adaptive?.record({
|
|
515
|
+
latencyMs: since(now, writeStarted),
|
|
516
|
+
overloaded: throttle.takeLagged?.() === true,
|
|
517
|
+
});
|
|
518
|
+
if (change) ctx.onThrottle?.(change);
|
|
519
|
+
const { badIds, docErrors, left } = errorsOf(result.errors, [...result.left]);
|
|
520
|
+
const next = job.partitioner.advance(cursor, docs, { limit: batchSize, left });
|
|
521
|
+
const counts = countsOf(docs, result);
|
|
522
|
+
await store.checkpoint(lease, {
|
|
523
|
+
cursor: next ?? cursor,
|
|
524
|
+
counters: counts,
|
|
525
|
+
badIds,
|
|
526
|
+
docErrors,
|
|
527
|
+
done: next === null,
|
|
528
|
+
...(adaptive ? { throttle: adaptive.state() } : {}),
|
|
529
|
+
});
|
|
530
|
+
return { result, next, counts };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** A transaction's retry backoff: full jitter, up to 50 ms · 2ⁿ, never past 2 s */
|
|
534
|
+
const TXN_BACKOFF_BASE_MS = 50;
|
|
535
|
+
const TXN_BACKOFF_MAX_MS = 2_000;
|
|
536
|
+
const backoffMs = (attempt) =>
|
|
537
|
+
Math.random() * Math.min(TXN_BACKOFF_MAX_MS, TXN_BACKOFF_BASE_MS * 2 ** attempt);
|
|
538
|
+
|
|
539
|
+
/** A transactional batch halved on trouble grows back ×1.5 after this many clean ones */
|
|
540
|
+
const CLEAN_BATCHES_TO_GROW = 5;
|
|
541
|
+
const BATCH_GROWTH = 1.5;
|
|
542
|
+
|
|
543
|
+
/** The server's ceiling on what one batch reads into a transaction — well under 16 MiB */
|
|
544
|
+
const MAX_TRANSACTION_BYTES = 8 * 1024 * 1024;
|
|
545
|
+
|
|
546
|
+
/** Errors that mean "the batch took too long or grew too big" — halve it and retry */
|
|
547
|
+
const TIME_OR_SIZE = new Set([50, 262, 290, 334, 10334, 17419]);
|
|
548
|
+
|
|
549
|
+
const isTransientTransaction = (error) =>
|
|
550
|
+
error?.hasErrorLabel?.('TransientTransactionError') === true ||
|
|
551
|
+
error?.code === 112 ||
|
|
552
|
+
error?.code === 251 ||
|
|
553
|
+
error?.code === 24;
|
|
554
|
+
|
|
555
|
+
const isUnknownCommit = (error) =>
|
|
556
|
+
error?.hasErrorLabel?.('UnknownTransactionCommitResult') === true;
|
|
557
|
+
|
|
558
|
+
/** The leading documents of `docs` whose BSON fits in `bytes` — at least one */
|
|
559
|
+
function fitting(docs, bytes) {
|
|
560
|
+
let total = 0;
|
|
561
|
+
for (let i = 0; i < docs.length; i++) {
|
|
562
|
+
total += bsonSize(docs[i]);
|
|
563
|
+
if (total > bytes && i > 0) return docs.slice(0, i);
|
|
564
|
+
}
|
|
565
|
+
return docs;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function commit(session) {
|
|
569
|
+
for (let attempt = 0; ; attempt++) {
|
|
570
|
+
try {
|
|
571
|
+
await session.commitTransaction();
|
|
572
|
+
return;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
// The commit may have landed: commit again — it is idempotent.
|
|
575
|
+
if (!isUnknownCommit(error) || attempt >= 2) throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* One batch in a transaction: the read (snapshot), the transformations —
|
|
582
|
+
* with `ctx.session` for their own side writes — the guarded writes and the
|
|
583
|
+
* fenced checkpoint all commit together, or not at all. So the counters are
|
|
584
|
+
* exact, a stale lease holder cannot commit (its checkpoint matches nothing),
|
|
585
|
+
* and a side write happens once per document that is migrated.
|
|
586
|
+
*
|
|
587
|
+
* A document that fails (its transformation, the validator, a duplicate key)
|
|
588
|
+
* aborts the batch, which is retried without it; a document a concurrent
|
|
589
|
+
* write moved aborts it too, and is read again in the retry. Transient
|
|
590
|
+
* transaction errors back off (full jitter, 50 ms · 2ⁿ up to 2 s) up to
|
|
591
|
+
* `maxRetries`, halving the batch after half of them; a batch that ran out of
|
|
592
|
+
* time or room is halved at once. Alone in a batch, a conflicting document is
|
|
593
|
+
* skipped (the next pass takes it) and one too big or too slow is a document
|
|
594
|
+
* error. Clean batches grow back ×1.5.
|
|
595
|
+
*/
|
|
596
|
+
async function transactionalBatch(job, ctx, cursor, batchSize) {
|
|
597
|
+
const { store, lease, partition, db, client, signal } = ctx;
|
|
598
|
+
const spec = job.spec;
|
|
599
|
+
const { timeoutMs, maxRetries } = spec.transaction;
|
|
600
|
+
const collection = db.collection(spec.collection);
|
|
601
|
+
ctx.txn ??= { size: batchSize, clean: 0 };
|
|
602
|
+
let size = Math.min(ctx.txn.size, batchSize);
|
|
603
|
+
let attempts = 0;
|
|
604
|
+
let txnRetries = 0;
|
|
605
|
+
const excluded = new Map();
|
|
606
|
+
for (;;) {
|
|
607
|
+
if (signal?.aborted) throw signal.reason;
|
|
608
|
+
const session = client.startSession();
|
|
609
|
+
let docs = [];
|
|
610
|
+
try {
|
|
611
|
+
session.startTransaction(transactionOptions(spec));
|
|
612
|
+
const query = job.partitioner.batchQuery(partition.scope, cursor, {
|
|
613
|
+
limit: size,
|
|
614
|
+
match: job.match,
|
|
615
|
+
hint: job.hint,
|
|
616
|
+
});
|
|
617
|
+
const fetched = await collection
|
|
618
|
+
.find(query.filter, { ...query.options, session, maxTimeMS: timeoutMs })
|
|
619
|
+
.toArray();
|
|
620
|
+
docs = fitting(fetched, MAX_TRANSACTION_BYTES);
|
|
621
|
+
const work = [];
|
|
622
|
+
for (const doc of docs) if (!excluded.has(idKey(doc._id))) work.push(doc);
|
|
623
|
+
const result =
|
|
624
|
+
work.length > 0
|
|
625
|
+
? await applyBatch(job, work, {
|
|
626
|
+
db,
|
|
627
|
+
session,
|
|
628
|
+
ctxExtra: { session, db, client },
|
|
629
|
+
abortOnConflict: true,
|
|
630
|
+
strict: true,
|
|
631
|
+
})
|
|
632
|
+
: { migrated: 0, skipped: 0, conflicts: 0, retried: 0, errors: [], left: [] };
|
|
633
|
+
const errors = [...excluded.values()];
|
|
634
|
+
result.errors = errors;
|
|
635
|
+
const { badIds, docErrors, left } = errorsOf(errors);
|
|
636
|
+
const next =
|
|
637
|
+
docs.length < fetched.length
|
|
638
|
+
? job.partitioner.past(cursor, docs, { left })
|
|
639
|
+
: job.partitioner.advance(cursor, fetched, { limit: size, left });
|
|
640
|
+
const counts = countsOf(docs, result, txnRetries > 0 ? { txnRetries } : {});
|
|
641
|
+
// The last write of the transaction: only the lease holder's commits.
|
|
642
|
+
await store.checkpoint(
|
|
643
|
+
lease,
|
|
644
|
+
{
|
|
645
|
+
cursor: next ?? cursor,
|
|
646
|
+
counters: counts,
|
|
647
|
+
badIds,
|
|
648
|
+
docErrors,
|
|
649
|
+
done: next === null,
|
|
650
|
+
},
|
|
651
|
+
{ session },
|
|
652
|
+
);
|
|
653
|
+
await commit(session);
|
|
654
|
+
lease.touch();
|
|
655
|
+
ctx.txn.clean += 1;
|
|
656
|
+
if (ctx.txn.clean >= CLEAN_BATCHES_TO_GROW && size < batchSize) {
|
|
657
|
+
ctx.txn.size = Math.min(batchSize, Math.ceil(size * BATCH_GROWTH));
|
|
658
|
+
ctx.txn.clean = 0;
|
|
659
|
+
} else {
|
|
660
|
+
ctx.txn.size = size;
|
|
661
|
+
}
|
|
662
|
+
if (txnRetries > 0) ctx.onTransactionRetries?.(txnRetries);
|
|
663
|
+
return { result, next, counts };
|
|
664
|
+
} catch (error) {
|
|
665
|
+
await session.abortTransaction().catch(() => undefined);
|
|
666
|
+
if (error instanceof LockLostError) throw error;
|
|
667
|
+
if (error?.context?.reason === 'document') {
|
|
668
|
+
const entry = error.context.document;
|
|
669
|
+
excluded.set(idKey(entry.id), entry);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const conflict = error?.context?.reason === 'write-conflict';
|
|
673
|
+
const timeOrSize = TIME_OR_SIZE.has(error?.code);
|
|
674
|
+
if (!conflict && !timeOrSize && !isTransientTransaction(error)) throw error;
|
|
675
|
+
txnRetries += 1;
|
|
676
|
+
ctx.txn.clean = 0;
|
|
677
|
+
if (size === 1 && docs.length === 1) {
|
|
678
|
+
const [doc] = docs;
|
|
679
|
+
if (timeOrSize) {
|
|
680
|
+
excluded.set(idKey(doc._id), docError(doc, error, 'transaction'));
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (conflict) {
|
|
684
|
+
// Alone and still in the way: past it — the next pass takes it.
|
|
685
|
+
const next = job.partitioner.past(cursor, [doc], { left: [doc._id] });
|
|
686
|
+
const counts = { scanned: 1, conflicts: 1, batches: 1, txnRetries };
|
|
687
|
+
await store.checkpoint(lease, { cursor: next, counters: counts });
|
|
688
|
+
return {
|
|
689
|
+
result: { migrated: 0, skipped: 0, conflicts: 1, retried: 0, errors: [] },
|
|
690
|
+
next,
|
|
691
|
+
counts,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Too slow or too big: halve the batch at once. At one document that
|
|
696
|
+
// is not the answer any more (the read itself ran out of time — a
|
|
697
|
+
// filter the index does not cover): it is retried like a transient
|
|
698
|
+
// error, with a backoff, and fails the slice after maxRetries.
|
|
699
|
+
if (timeOrSize && size > 1) {
|
|
700
|
+
size = Math.max(1, Math.floor(size / 2));
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
attempts += 1;
|
|
704
|
+
if (attempts > maxRetries) throw error;
|
|
705
|
+
if (attempts > maxRetries / 2) size = Math.max(1, Math.floor(size / 2));
|
|
706
|
+
await sleep(backoffMs(attempts), signal);
|
|
707
|
+
} finally {
|
|
708
|
+
await session.endSession().catch(() => undefined);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Work one partition until it is done, the slice's deadline passes, a
|
|
715
|
+
* control or a new plan says stop, or the lease is lost. `ctx`:
|
|
716
|
+
* `{ store, lease, partition, db, client, signal, deadline, throttle,
|
|
717
|
+
* readControl, onBatch?, now? }` — `readControl()` resolves to the state's
|
|
718
|
+
* `{ status, generation, plan }`; `onBatch(info)` hears every batch.
|
|
719
|
+
*
|
|
720
|
+
* Resolves to `{ outcome, counters }` — `outcome` one of `exhausted` (the
|
|
721
|
+
* partition is done), `yielded` (the slice ended), `paused`, `cancelled`,
|
|
722
|
+
* `failed` (the state failed, or the error budget ran out — with `error`),
|
|
723
|
+
* `stale` (another plan took over) or `stopped` (the signal). A lost lease
|
|
724
|
+
* throws LockLostError; a failure of the slice itself throws.
|
|
725
|
+
*/
|
|
726
|
+
async function processPartition(job, ctx) {
|
|
727
|
+
const { store, partition, signal, throttle, readControl } = ctx;
|
|
728
|
+
const now = ctx.now ?? Date.now;
|
|
729
|
+
const spec = job.spec;
|
|
730
|
+
const counters = {};
|
|
731
|
+
let cursor = partition.cursor ?? {};
|
|
732
|
+
let batches = 0;
|
|
733
|
+
for (;;) {
|
|
734
|
+
if (signal?.aborted) return { outcome: 'stopped', counters };
|
|
735
|
+
// At least one batch per partition claimed, however short the slice.
|
|
736
|
+
if (batches > 0 && now() >= ctx.deadline) return { outcome: 'yielded', counters, cursor };
|
|
737
|
+
const control = await readControl();
|
|
738
|
+
const stop = controlOutcome(control, job, partition);
|
|
739
|
+
if (stop) return { outcome: stop, counters };
|
|
740
|
+
const adaptive = ctx.adaptive;
|
|
741
|
+
const batchSize = adaptive?.batchSize() ?? spec.batchSize;
|
|
742
|
+
await throttle.beforeBatch({
|
|
743
|
+
signal,
|
|
744
|
+
generation: job.generation,
|
|
745
|
+
partition: String(partition._id),
|
|
746
|
+
batchSize,
|
|
747
|
+
extraPauseMs: adaptive?.pauseMs() ?? 0,
|
|
748
|
+
});
|
|
749
|
+
const started = now();
|
|
750
|
+
let next;
|
|
751
|
+
let batchCounts;
|
|
752
|
+
if (spec.mode === 'step') {
|
|
753
|
+
const stepped = await runStep(job, ctx, cursor);
|
|
754
|
+
next = stepped.next;
|
|
755
|
+
batchCounts = stepped.counts;
|
|
756
|
+
} else {
|
|
757
|
+
const batch = spec.transaction
|
|
758
|
+
? await transactionalBatch(job, ctx, cursor, batchSize)
|
|
759
|
+
: await plainBatch(job, ctx, cursor, batchSize);
|
|
760
|
+
const { result } = batch;
|
|
761
|
+
next = batch.next;
|
|
762
|
+
batchCounts = batch.counts;
|
|
763
|
+
if (result.errors.length > 0) {
|
|
764
|
+
const total = await store.countBadIds(job.name, job.generation);
|
|
765
|
+
if (total > spec.maxDocumentErrors) {
|
|
766
|
+
const error = new BackgroundFailedError(
|
|
767
|
+
`Background migration ${job.name} failed: ${total} document(s) could not be ` +
|
|
768
|
+
`migrated, more than maxDocumentErrors (${spec.maxDocumentErrors}) — last: ` +
|
|
769
|
+
result.errors[result.errors.length - 1].error,
|
|
770
|
+
{
|
|
771
|
+
migration: job.name,
|
|
772
|
+
failedDocuments: total,
|
|
773
|
+
maxDocumentErrors: spec.maxDocumentErrors,
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
addCounters(counters, batchCounts);
|
|
777
|
+
return { outcome: 'failed', counters, error };
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
addCounters(counters, batchCounts);
|
|
782
|
+
batches += 1;
|
|
783
|
+
ctx.onBatch?.({ counters: batchCounts, latencyMs: since(now, started), batchSize });
|
|
784
|
+
if (next === null) return { outcome: 'exhausted', counters };
|
|
785
|
+
cursor = next;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** What the state's controls mean for a lane on this partition — `undefined`: go on */
|
|
790
|
+
function controlOutcome(control, job, partition) {
|
|
791
|
+
if (control === null) return 'cancelled';
|
|
792
|
+
if (control.status === 'paused') return 'paused';
|
|
793
|
+
if (control.status === 'cancelled') return 'cancelled';
|
|
794
|
+
if (control.status === 'failed') return 'failed';
|
|
795
|
+
if (control.status !== 'running') return 'stale';
|
|
796
|
+
if (control.generation !== job.generation || control.plan?.token !== partition.plan) {
|
|
797
|
+
return 'stale';
|
|
798
|
+
}
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
module.exports = {
|
|
803
|
+
DOCUMENT_ERRORS,
|
|
804
|
+
applyBatch,
|
|
805
|
+
transformAll,
|
|
806
|
+
isOverload,
|
|
807
|
+
buildStepContext,
|
|
808
|
+
controlOutcome,
|
|
809
|
+
addCounters,
|
|
810
|
+
directionOf,
|
|
811
|
+
excludeBadIds,
|
|
812
|
+
idKey,
|
|
813
|
+
matchOf,
|
|
814
|
+
processPartition,
|
|
815
|
+
readStepResult,
|
|
816
|
+
transactionOptions,
|
|
817
|
+
transformContext,
|
|
818
|
+
};
|