@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,951 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const { ConfigInvalidError, LockLostError } = require('../errors/index.js');
|
|
3
|
+
const { randomId } = require('../utils/id.js');
|
|
4
|
+
const { MAX_BAD_IDS } = require('./background-spec.js');
|
|
5
|
+
const { backgroundCollectionNames } = require('./config.js');
|
|
6
|
+
const { READ_OPTIONS } = require('./server-info.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where background migrations keep their state, in MongoDB — the truth every
|
|
10
|
+
* runtime (a BullMQ worker, the CLI, an in-process runner) reads and writes;
|
|
11
|
+
* Redis and processes are only executors.
|
|
12
|
+
*
|
|
13
|
+
* - One **state document** per background migration (`_id` = its file name):
|
|
14
|
+
* status, phase, the current plan, totals, controls, history.
|
|
15
|
+
* - One **partition** document per range of the current plan: its cursor,
|
|
16
|
+
* counters and — while a lane works it — its **lease**. The lease *is* the
|
|
17
|
+
* lane's slot: a unique partial index on `{ background, lease.slot }` makes
|
|
18
|
+
* "at most `maxParallel` partitions at once" an invariant of the schema,
|
|
19
|
+
* across every process, with no lock of its own to heartbeat.
|
|
20
|
+
*
|
|
21
|
+
* Every lease write is fenced by the lease's token, and every staleness
|
|
22
|
+
* decision is made in server time (`$$NOW`). Mechanism only: no logger, no
|
|
23
|
+
* decisions — background.js and the engine decide.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The state document's schema — a worker refuses a newer one (an old release mid-deploy) */
|
|
27
|
+
const STATE_SCHEMA = 2;
|
|
28
|
+
|
|
29
|
+
/** The last document errors kept, per partition and in the state */
|
|
30
|
+
const MAX_DOC_ERRORS = 20;
|
|
31
|
+
|
|
32
|
+
/** History entries kept on a state document */
|
|
33
|
+
const MAX_HISTORY = 50;
|
|
34
|
+
|
|
35
|
+
/** Statuses of a partition still to be worked */
|
|
36
|
+
const OPEN_PARTITION = ['pending', 'running'];
|
|
37
|
+
|
|
38
|
+
const DUPLICATE_KEY = 11000;
|
|
39
|
+
|
|
40
|
+
/** A value as itself inside an update pipeline — a string with a leading `$` is not a field path */
|
|
41
|
+
const literal = (value) => ({ $literal: value });
|
|
42
|
+
|
|
43
|
+
/** `field + n` inside a pipeline, a missing field counting as 0 */
|
|
44
|
+
const plus = (field, n) => ({ $add: [{ $ifNull: [`$${field}`, 0] }, n] });
|
|
45
|
+
|
|
46
|
+
/** An array field with `items` appended, keeping the last `max` */
|
|
47
|
+
const appendSliced = (field, items, max) => ({
|
|
48
|
+
$slice: [{ $concatArrays: [{ $ifNull: [`$${field}`, []] }, literal(items)] }, -max],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** What the indexes are: the state's listing order, the claims, and the lease slots */
|
|
52
|
+
const INDEXES = [
|
|
53
|
+
{ on: 'state', key: { status: 1, registeredAt: 1 }, options: { name: 'status_registeredAt' } },
|
|
54
|
+
{
|
|
55
|
+
on: 'partitions',
|
|
56
|
+
key: { background: 1, generation: 1, plan: 1, status: 1, seq: 1 },
|
|
57
|
+
options: { name: 'claim' },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
on: 'partitions',
|
|
61
|
+
key: { background: 1, 'lease.slot': 1 },
|
|
62
|
+
options: {
|
|
63
|
+
name: 'lease_slot',
|
|
64
|
+
unique: true,
|
|
65
|
+
partialFilterExpression: { 'lease.slot': { $exists: true } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
on: 'partitions',
|
|
70
|
+
key: { background: 1, 'lease.groupSlot': 1 },
|
|
71
|
+
options: {
|
|
72
|
+
name: 'lease_group_slot',
|
|
73
|
+
unique: true,
|
|
74
|
+
partialFilterExpression: { 'lease.groupSlot': { $exists: true } },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const NAMESPACE_NOT_FOUND = 26;
|
|
80
|
+
|
|
81
|
+
class BackgroundStore {
|
|
82
|
+
#db;
|
|
83
|
+
#names;
|
|
84
|
+
#indexed;
|
|
85
|
+
#asserted;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `collection` is the `backgroundCollection` setting: the state lives
|
|
89
|
+
* there, the partitions in `<collection>_partitions`.
|
|
90
|
+
*/
|
|
91
|
+
constructor(db, collection) {
|
|
92
|
+
this.#db = db;
|
|
93
|
+
this.#names = backgroundCollectionNames(collection);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get names() {
|
|
97
|
+
return this.#names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get #states() {
|
|
101
|
+
return this.#db.collection(this.#names.state);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get #partitions() {
|
|
105
|
+
return this.#db.collection(this.#names.partitions);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The indexes, created on first use — never at connect: a database that
|
|
110
|
+
* never registers a background migration gets no collections at all. Must
|
|
111
|
+
* run outside a transaction (a registration inside `useTransaction` calls
|
|
112
|
+
* it first).
|
|
113
|
+
*/
|
|
114
|
+
async ensureIndexes() {
|
|
115
|
+
this.#indexed ??= Promise.all(
|
|
116
|
+
INDEXES.map(({ on, key, options }) => this.#collection(on).createIndex(key, options)),
|
|
117
|
+
).catch((error) => {
|
|
118
|
+
this.#indexed = undefined;
|
|
119
|
+
throw error;
|
|
120
|
+
});
|
|
121
|
+
await this.#indexed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* For `ensureIndexes: false` (a user who cannot create indexes): the lease
|
|
126
|
+
* slots are capped by unique indexes, so a lane refuses to claim without
|
|
127
|
+
* them rather than run past `maxParallel`. Checked once per store.
|
|
128
|
+
*
|
|
129
|
+
* @throws {ConfigInvalidError} naming each missing index and its key
|
|
130
|
+
*/
|
|
131
|
+
async assertIndexes() {
|
|
132
|
+
this.#asserted ??= (async () => {
|
|
133
|
+
const missing = [];
|
|
134
|
+
const present = new Map();
|
|
135
|
+
for (const on of ['state', 'partitions']) {
|
|
136
|
+
let names = new Set();
|
|
137
|
+
try {
|
|
138
|
+
const indexes = await this.#collection(on).listIndexes().toArray();
|
|
139
|
+
names = new Set(indexes.map((index) => index.name));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error?.code !== NAMESPACE_NOT_FOUND) throw error;
|
|
142
|
+
}
|
|
143
|
+
present.set(on, names);
|
|
144
|
+
}
|
|
145
|
+
for (const { on, key, options } of INDEXES) {
|
|
146
|
+
if (!present.get(on).has(options.name)) {
|
|
147
|
+
missing.push(`${this.#names[on]}: ${JSON.stringify(key)} (${options.name})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
throw new ConfigInvalidError(
|
|
152
|
+
'Background migrations need their indexes, and ensureIndexes is false — create ' +
|
|
153
|
+
`them (or let migronaut): ${missing.join('; ')}`,
|
|
154
|
+
{ missing },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
})().catch((error) => {
|
|
158
|
+
this.#asserted = undefined;
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
await this.#asserted;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#collection(on) {
|
|
165
|
+
return on === 'state' ? this.#states : this.#partitions;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── State ──────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** A state document, read from the primary — or `null`; `projection` to read less of it */
|
|
171
|
+
get(name, { session, projection } = {}) {
|
|
172
|
+
return this.#states.findOne(
|
|
173
|
+
{ _id: name },
|
|
174
|
+
{ ...READ_OPTIONS, ...(session ? { session } : {}), ...(projection ? { projection } : {}) },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The state documents of `names`, by name — one read */
|
|
179
|
+
async getMany(names) {
|
|
180
|
+
const byName = new Map();
|
|
181
|
+
if (names.length === 0) return byName;
|
|
182
|
+
const docs = await this.#states.find({ _id: { $in: names } }, READ_OPTIONS).toArray();
|
|
183
|
+
for (const doc of docs) byName.set(doc._id, doc);
|
|
184
|
+
return byName;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Every state document matching `filter`, oldest registration first —
|
|
189
|
+
* `projection` to leave out what a frequent reader does not need (the
|
|
190
|
+
* history, the document errors).
|
|
191
|
+
*/
|
|
192
|
+
list(filter = {}, { projection } = {}) {
|
|
193
|
+
return this.#states
|
|
194
|
+
.find(filter, { ...READ_OPTIONS, ...(projection ? { projection } : {}) })
|
|
195
|
+
.sort({ registeredAt: 1, _id: 1 })
|
|
196
|
+
.toArray();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Register (or register again) a background migration: a fresh state
|
|
201
|
+
* document under a new `registration` id, and no partitions — a plan of
|
|
202
|
+
* the old registration must not be resumed against the new one. A new
|
|
203
|
+
* registration keeps the old one's history, and a summary of it as
|
|
204
|
+
* `previous` (a revert replacing a forward run must not erase its trail).
|
|
205
|
+
*/
|
|
206
|
+
async register(name, fields, { session } = {}) {
|
|
207
|
+
const options = session ? { session } : {};
|
|
208
|
+
const now = new Date();
|
|
209
|
+
const old = await this.#states.findOne(
|
|
210
|
+
{ _id: name },
|
|
211
|
+
{
|
|
212
|
+
projection: {
|
|
213
|
+
registration: 1,
|
|
214
|
+
status: 1,
|
|
215
|
+
direction: 1,
|
|
216
|
+
pass: 1,
|
|
217
|
+
totals: 1,
|
|
218
|
+
registeredAt: 1,
|
|
219
|
+
completedAt: 1,
|
|
220
|
+
history: 1,
|
|
221
|
+
},
|
|
222
|
+
...READ_OPTIONS,
|
|
223
|
+
...options,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
const history = Array.isArray(old?.history) ? old.history.slice(-(MAX_HISTORY - 1)) : [];
|
|
227
|
+
history.push({
|
|
228
|
+
at: now,
|
|
229
|
+
action: 'register',
|
|
230
|
+
...(old ? { from: old.status } : {}),
|
|
231
|
+
to: fields.status,
|
|
232
|
+
});
|
|
233
|
+
const doc = {
|
|
234
|
+
_id: name,
|
|
235
|
+
schema: STATE_SCHEMA,
|
|
236
|
+
registration: randomId(),
|
|
237
|
+
phase: 'partition',
|
|
238
|
+
pass: 0,
|
|
239
|
+
generation: 0,
|
|
240
|
+
rolledGeneration: 0,
|
|
241
|
+
totals: {},
|
|
242
|
+
badIds: [],
|
|
243
|
+
docErrors: [],
|
|
244
|
+
failures: 0,
|
|
245
|
+
reopened: 0,
|
|
246
|
+
history,
|
|
247
|
+
...(old
|
|
248
|
+
? {
|
|
249
|
+
previous: {
|
|
250
|
+
registration: old.registration,
|
|
251
|
+
status: old.status,
|
|
252
|
+
direction: old.direction,
|
|
253
|
+
pass: old.pass,
|
|
254
|
+
totals: old.totals ?? {},
|
|
255
|
+
registeredAt: old.registeredAt,
|
|
256
|
+
...(old.completedAt ? { completedAt: old.completedAt } : {}),
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
260
|
+
registeredAt: now,
|
|
261
|
+
updatedAt: now,
|
|
262
|
+
...fields,
|
|
263
|
+
};
|
|
264
|
+
// The old partitions go first: a crash in between leaves a state with no
|
|
265
|
+
// partitions (the coordinator plans again), never partitions of an old
|
|
266
|
+
// registration that a new one's generation numbers would count as its own.
|
|
267
|
+
await this.#partitions.deleteMany({ background: name }, options);
|
|
268
|
+
await this.#states.replaceOne({ _id: name }, doc, { upsert: true, ...options });
|
|
269
|
+
return doc;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Compare-and-set a state document: `filter` on top of the `_id`, `update`
|
|
274
|
+
* an operator update or a pipeline. Returns the document after, or `null`
|
|
275
|
+
* when the filter no longer matched.
|
|
276
|
+
*/
|
|
277
|
+
cas(name, filter, update, { session } = {}) {
|
|
278
|
+
return this.#states.findOneAndUpdate({ _id: name, ...filter }, update, {
|
|
279
|
+
returnDocument: 'after',
|
|
280
|
+
...READ_OPTIONS,
|
|
281
|
+
...(session ? { session } : {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Move a state document from one of `from` to `to`, appending a history
|
|
287
|
+
* entry and setting `fields`. Returns the document after, or `null` when
|
|
288
|
+
* its status was no longer one of `from` (someone else moved it).
|
|
289
|
+
*/
|
|
290
|
+
move(name, { from, to, action, fields = {}, filter = {}, by, reason }) {
|
|
291
|
+
const entry = {
|
|
292
|
+
at: '$$NOW',
|
|
293
|
+
action: literal(action),
|
|
294
|
+
from: '$status',
|
|
295
|
+
to: literal(to),
|
|
296
|
+
...(by !== undefined ? { by: literal(by) } : {}),
|
|
297
|
+
...(reason !== undefined ? { reason: literal(reason) } : {}),
|
|
298
|
+
};
|
|
299
|
+
// One stage: `$status` in the entry is the status before it.
|
|
300
|
+
const set = {
|
|
301
|
+
status: literal(to),
|
|
302
|
+
updatedAt: '$$NOW',
|
|
303
|
+
history: {
|
|
304
|
+
$slice: [{ $concatArrays: [{ $ifNull: ['$history', []] }, [entry]] }, -MAX_HISTORY],
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
for (const [key, value] of Object.entries(fields)) set[key] = literal(value);
|
|
308
|
+
return this.cas(name, { status: { $in: from }, ...filter }, [{ $set: set }]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Append a history entry with no status change — a control that is not a
|
|
313
|
+
* transition (an unlock). `null` when it is not registered.
|
|
314
|
+
*/
|
|
315
|
+
note(name, { action, by, reason, ...details }) {
|
|
316
|
+
const entry = {
|
|
317
|
+
at: '$$NOW',
|
|
318
|
+
action: literal(action),
|
|
319
|
+
...(by !== undefined ? { by: literal(by) } : {}),
|
|
320
|
+
...(reason !== undefined ? { reason: literal(reason) } : {}),
|
|
321
|
+
};
|
|
322
|
+
for (const [key, value] of Object.entries(details)) entry[key] = literal(value);
|
|
323
|
+
return this.cas(name, {}, [
|
|
324
|
+
{
|
|
325
|
+
$set: {
|
|
326
|
+
updatedAt: '$$NOW',
|
|
327
|
+
history: {
|
|
328
|
+
$slice: [{ $concatArrays: [{ $ifNull: ['$history', []] }, [entry]] }, -MAX_HISTORY],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Set fields of a state document (no status change) */
|
|
336
|
+
async set(name, fields, { filter = {}, session } = {}) {
|
|
337
|
+
const result = await this.#states.updateOne(
|
|
338
|
+
{ _id: name, ...filter },
|
|
339
|
+
{ $set: { ...fields, updatedAt: new Date() } },
|
|
340
|
+
session ? { session } : {},
|
|
341
|
+
);
|
|
342
|
+
return result.matchedCount === 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Delete a state document and its partitions — with `filter`, only while
|
|
347
|
+
* the state still matches it (`{ generation: 0 }`: no plan was ever
|
|
348
|
+
* committed, so no lane can have written). Returns whether it was deleted.
|
|
349
|
+
*/
|
|
350
|
+
async remove(name, { session, filter } = {}) {
|
|
351
|
+
const options = session ? { session } : {};
|
|
352
|
+
const result = await this.#states.deleteOne({ _id: name, ...filter }, options);
|
|
353
|
+
if (filter !== undefined && result.deletedCount !== 1) return false;
|
|
354
|
+
await this.#partitions.deleteMany({ background: name }, options);
|
|
355
|
+
return result.deletedCount === 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Plans ──────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Commit a new plan: its partitions inserted under a fresh plan token, then
|
|
362
|
+
* the state moved to them in one compare-and-set on the generation and the
|
|
363
|
+
* plan it was planned against. A coordinator that lost that race (another
|
|
364
|
+
* one committed first) leaves only partitions nobody can claim — their
|
|
365
|
+
* plan is not the state's — and removes them. Returns the state after, or
|
|
366
|
+
* `null` when the race was lost.
|
|
367
|
+
*/
|
|
368
|
+
async commitPlan(
|
|
369
|
+
name,
|
|
370
|
+
{ generation, previousToken, plan, partitions, fields = {}, filter = {} },
|
|
371
|
+
) {
|
|
372
|
+
const token = randomId();
|
|
373
|
+
const nextGeneration = generation + 1;
|
|
374
|
+
if (partitions.length > 0) {
|
|
375
|
+
const docs = partitions.map((partition, seq) => ({
|
|
376
|
+
background: name,
|
|
377
|
+
generation: nextGeneration,
|
|
378
|
+
plan: token,
|
|
379
|
+
seq,
|
|
380
|
+
scope: partition.scope,
|
|
381
|
+
status: 'pending',
|
|
382
|
+
...(partition.group !== undefined ? { group: partition.group } : {}),
|
|
383
|
+
estimate: partition.estimate ?? 0,
|
|
384
|
+
cursor: {},
|
|
385
|
+
counters: {},
|
|
386
|
+
claims: 0,
|
|
387
|
+
reclaims: 0,
|
|
388
|
+
failures: 0,
|
|
389
|
+
createdAt: new Date(),
|
|
390
|
+
}));
|
|
391
|
+
await this.#partitions.insertMany(docs, { ordered: false });
|
|
392
|
+
}
|
|
393
|
+
const state = await this.cas(
|
|
394
|
+
name,
|
|
395
|
+
{ ...filter, generation, 'plan.token': previousToken ?? { $exists: false } },
|
|
396
|
+
{
|
|
397
|
+
$set: {
|
|
398
|
+
generation: nextGeneration,
|
|
399
|
+
plan: {
|
|
400
|
+
...plan,
|
|
401
|
+
token,
|
|
402
|
+
generation: nextGeneration,
|
|
403
|
+
partitions: partitions.length,
|
|
404
|
+
at: new Date(),
|
|
405
|
+
},
|
|
406
|
+
phase: 'process',
|
|
407
|
+
updatedAt: new Date(),
|
|
408
|
+
...fields,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
if (state === null) {
|
|
413
|
+
await this.#partitions.deleteMany({ background: name, plan: token });
|
|
414
|
+
}
|
|
415
|
+
return state;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Mark the open partitions of a plan superseded — a replan is coming */
|
|
419
|
+
async supersede(name, { generation, plan }) {
|
|
420
|
+
const result = await this.#partitions.updateMany(
|
|
421
|
+
{ background: name, generation, plan, status: { $in: OPEN_PARTITION } },
|
|
422
|
+
{ $set: { status: 'superseded', updatedAt: new Date() } },
|
|
423
|
+
);
|
|
424
|
+
return result.modifiedCount;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** How the partitions of a plan stand: a count per status, and how many are leased */
|
|
428
|
+
async partitionCounts(name, { generation, plan }) {
|
|
429
|
+
const rows = await this.#partitions
|
|
430
|
+
.aggregate(
|
|
431
|
+
[
|
|
432
|
+
{ $match: { background: name, generation, plan } },
|
|
433
|
+
{
|
|
434
|
+
$group: {
|
|
435
|
+
_id: '$status',
|
|
436
|
+
count: { $sum: 1 },
|
|
437
|
+
leased: { $sum: { $cond: [{ $gt: ['$lease', null] }, 1, 0] } },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
READ_OPTIONS,
|
|
442
|
+
)
|
|
443
|
+
.toArray();
|
|
444
|
+
const counts = {
|
|
445
|
+
total: 0,
|
|
446
|
+
pending: 0,
|
|
447
|
+
running: 0,
|
|
448
|
+
done: 0,
|
|
449
|
+
failed: 0,
|
|
450
|
+
cancelled: 0,
|
|
451
|
+
superseded: 0,
|
|
452
|
+
leased: 0,
|
|
453
|
+
};
|
|
454
|
+
for (const row of rows) {
|
|
455
|
+
counts[row._id] = row.count;
|
|
456
|
+
counts.total += row.count;
|
|
457
|
+
counts.leased += row.leased;
|
|
458
|
+
}
|
|
459
|
+
return counts;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The partitions of one background migration — newest generation first, by seq */
|
|
463
|
+
partitions(name, filter = {}) {
|
|
464
|
+
return this.#partitions
|
|
465
|
+
.find({ background: name, ...filter }, READ_OPTIONS)
|
|
466
|
+
.sort({ generation: -1, seq: 1 })
|
|
467
|
+
.toArray();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The totals of a generation's partitions, for the state's roll-up: the
|
|
472
|
+
* counters summed, the bad ids united, the newest document errors.
|
|
473
|
+
*/
|
|
474
|
+
async generationTotals(name, generation) {
|
|
475
|
+
const [row] = await this.#partitions
|
|
476
|
+
.aggregate(
|
|
477
|
+
[
|
|
478
|
+
{ $match: { background: name, generation } },
|
|
479
|
+
{
|
|
480
|
+
$group: {
|
|
481
|
+
_id: null,
|
|
482
|
+
scanned: { $sum: { $ifNull: ['$counters.scanned', 0] } },
|
|
483
|
+
migrated: { $sum: { $ifNull: ['$counters.migrated', 0] } },
|
|
484
|
+
skipped: { $sum: { $ifNull: ['$counters.skipped', 0] } },
|
|
485
|
+
conflicts: { $sum: { $ifNull: ['$counters.conflicts', 0] } },
|
|
486
|
+
failed: { $sum: { $ifNull: ['$counters.failed', 0] } },
|
|
487
|
+
retried: { $sum: { $ifNull: ['$counters.retried', 0] } },
|
|
488
|
+
batches: { $sum: { $ifNull: ['$counters.batches', 0] } },
|
|
489
|
+
slices: { $sum: { $ifNull: ['$counters.slices', 0] } },
|
|
490
|
+
txnRetries: { $sum: { $ifNull: ['$counters.txnRetries', 0] } },
|
|
491
|
+
reclaims: { $sum: { $ifNull: ['$reclaims', 0] } },
|
|
492
|
+
badIds: { $push: { $ifNull: ['$badIds', []] } },
|
|
493
|
+
docErrors: { $push: { $ifNull: ['$lastDocErrors', []] } },
|
|
494
|
+
failedPartitions: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
|
|
495
|
+
// The error of a failed partition before any other, then the
|
|
496
|
+
// newest: a document compares field by field.
|
|
497
|
+
lastError: {
|
|
498
|
+
$max: {
|
|
499
|
+
failed: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] },
|
|
500
|
+
has: { $cond: [{ $gt: ['$lastError', null] }, 1, 0] },
|
|
501
|
+
at: '$updatedAt',
|
|
502
|
+
error: '$lastError',
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
READ_OPTIONS,
|
|
509
|
+
)
|
|
510
|
+
.toArray();
|
|
511
|
+
if (row === undefined) return null;
|
|
512
|
+
const { _id: _ignored, badIds, docErrors, lastError, ...rest } = row;
|
|
513
|
+
const totals = { ...rest, ...(lastError?.error ? { lastError: lastError.error } : {}) };
|
|
514
|
+
const ids = [];
|
|
515
|
+
for (const list of badIds) ids.push(...list);
|
|
516
|
+
const errors = [];
|
|
517
|
+
for (const list of docErrors) errors.push(...list);
|
|
518
|
+
return { totals, badIds: ids, docErrors: errors.slice(-MAX_DOC_ERRORS) };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The distinct documents that failed — the state's (earlier generations,
|
|
523
|
+
* already excluded from later passes) plus this generation's partitions'.
|
|
524
|
+
*/
|
|
525
|
+
async countBadIds(name, generation) {
|
|
526
|
+
const [state, rows] = await Promise.all([
|
|
527
|
+
this.#states.findOne({ _id: name }, { projection: { badIds: 1 }, ...READ_OPTIONS }),
|
|
528
|
+
this.#partitions
|
|
529
|
+
.aggregate(
|
|
530
|
+
[
|
|
531
|
+
{ $match: { background: name, generation, 'badIds.0': { $exists: true } } },
|
|
532
|
+
{ $unwind: '$badIds' },
|
|
533
|
+
{ $group: { _id: '$badIds' } },
|
|
534
|
+
{ $count: 'n' },
|
|
535
|
+
],
|
|
536
|
+
READ_OPTIONS,
|
|
537
|
+
)
|
|
538
|
+
.toArray(),
|
|
539
|
+
]);
|
|
540
|
+
return (state?.badIds?.length ?? 0) + (rows[0]?.n ?? 0);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* What a background migration has done so far: documents rewritten
|
|
545
|
+
* (`migrated`) and batches — or steps — checkpointed (`batches`), the
|
|
546
|
+
* rolled-up totals plus the partitions not rolled up yet. Zeros for one
|
|
547
|
+
* that is not registered. A step migration says how many documents it
|
|
548
|
+
* rewrote only when it wants to; every checkpointed step counts as a batch.
|
|
549
|
+
*/
|
|
550
|
+
async progress(name) {
|
|
551
|
+
const state = await this.get(name);
|
|
552
|
+
if (state === null) return { migrated: 0, batches: 0 };
|
|
553
|
+
const [row] = await this.#partitions
|
|
554
|
+
.aggregate(
|
|
555
|
+
[
|
|
556
|
+
{ $match: { background: name, generation: { $gt: state.rolledGeneration ?? 0 } } },
|
|
557
|
+
{
|
|
558
|
+
$group: {
|
|
559
|
+
_id: null,
|
|
560
|
+
migrated: { $sum: { $ifNull: ['$counters.migrated', 0] } },
|
|
561
|
+
batches: { $sum: { $ifNull: ['$counters.batches', 0] } },
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
READ_OPTIONS,
|
|
566
|
+
)
|
|
567
|
+
.toArray();
|
|
568
|
+
return {
|
|
569
|
+
migrated: (state.totals?.migrated ?? 0) + (row?.migrated ?? 0),
|
|
570
|
+
batches: (state.totals?.batches ?? 0) + (row?.batches ?? 0),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Partitions of a generation outside its current plan — what a lost commit race left */
|
|
575
|
+
countForeignPlans(name, { generation, plan }) {
|
|
576
|
+
return this.#partitions.countDocuments({ background: name, generation, plan: { $ne: plan } });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Delete the partitions of generations up to `generation` — `keep` spares one */
|
|
580
|
+
async dropGenerations(name, generation, { keep } = {}) {
|
|
581
|
+
const filter = { background: name, generation: { $lte: generation } };
|
|
582
|
+
if (keep !== undefined) filter.generation.$ne = keep;
|
|
583
|
+
const result = await this.#partitions.deleteMany(filter);
|
|
584
|
+
return result.deletedCount;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Delete the partitions of every plan but `plan` — what a lost commit race left behind */
|
|
588
|
+
async dropForeignPlans(name, plan) {
|
|
589
|
+
const result = await this.#partitions.deleteMany({
|
|
590
|
+
background: name,
|
|
591
|
+
plan: { $ne: plan },
|
|
592
|
+
status: { $in: [...OPEN_PARTITION, 'superseded'] },
|
|
593
|
+
});
|
|
594
|
+
return result.deletedCount;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Set the status of a plan's open partitions — `cancelled`, or `pending` again for a retry */
|
|
598
|
+
async setOpenPartitions(name, { generation, plan, from = OPEN_PARTITION, status }) {
|
|
599
|
+
const result = await this.#partitions.updateMany(
|
|
600
|
+
{ background: name, generation, plan, status: { $in: from } },
|
|
601
|
+
{ $set: { status, updatedAt: new Date(), ...(status === 'pending' ? { failures: 0 } : {}) } },
|
|
602
|
+
);
|
|
603
|
+
return result.modifiedCount;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─── Leases ─────────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Free the leases nobody renewed within their own TTL — in server time.
|
|
610
|
+
* Returns how many were reclaimed.
|
|
611
|
+
*/
|
|
612
|
+
async reap(name) {
|
|
613
|
+
const result = await this.#partitions.updateMany(
|
|
614
|
+
{
|
|
615
|
+
// Every lease has a slot: on that, the partial slot index serves the read.
|
|
616
|
+
background: name,
|
|
617
|
+
'lease.slot': { $exists: true },
|
|
618
|
+
$expr: { $lt: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
|
|
619
|
+
},
|
|
620
|
+
[{ $set: { reclaims: plus('reclaims', 1) } }, { $unset: 'lease' }],
|
|
621
|
+
);
|
|
622
|
+
return result.modifiedCount;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** How many leases are held — and how many were renewed within their TTL */
|
|
626
|
+
async leases(name) {
|
|
627
|
+
const [row] = await this.#partitions
|
|
628
|
+
.aggregate(
|
|
629
|
+
[
|
|
630
|
+
{ $match: { background: name, 'lease.slot': { $exists: true } } },
|
|
631
|
+
{
|
|
632
|
+
$group: {
|
|
633
|
+
_id: null,
|
|
634
|
+
held: { $sum: 1 },
|
|
635
|
+
live: {
|
|
636
|
+
$sum: {
|
|
637
|
+
$cond: [
|
|
638
|
+
{ $gte: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
|
|
639
|
+
1,
|
|
640
|
+
0,
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
READ_OPTIONS,
|
|
648
|
+
)
|
|
649
|
+
.toArray();
|
|
650
|
+
return { held: row?.held ?? 0, live: row?.live ?? 0 };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Leases renewed within their TTL, per background migration of `names` — one read */
|
|
654
|
+
async liveLeasesOf(names) {
|
|
655
|
+
const live = new Map();
|
|
656
|
+
if (names.length === 0) return live;
|
|
657
|
+
const rows = await this.#partitions
|
|
658
|
+
.aggregate(
|
|
659
|
+
[
|
|
660
|
+
{ $match: { background: { $in: names }, 'lease.slot': { $exists: true } } },
|
|
661
|
+
{
|
|
662
|
+
$match: {
|
|
663
|
+
$expr: { $gte: ['$lease.renewedAt', { $subtract: ['$$NOW', '$lease.ttlMs'] }] },
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{ $group: { _id: '$background', live: { $sum: 1 } } },
|
|
667
|
+
],
|
|
668
|
+
READ_OPTIONS,
|
|
669
|
+
)
|
|
670
|
+
.toArray();
|
|
671
|
+
for (const row of rows) live.set(row._id, row.live);
|
|
672
|
+
return live;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Drop every lease of a background migration — their holders are fenced off at once */
|
|
676
|
+
async unlockAll(name) {
|
|
677
|
+
const result = await this.#partitions.updateMany(
|
|
678
|
+
{ background: name, 'lease.slot': { $exists: true } },
|
|
679
|
+
{ $unset: { lease: '' } },
|
|
680
|
+
);
|
|
681
|
+
return result.modifiedCount;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Claim a partition of the current plan, and a slot with it, in one atomic
|
|
686
|
+
* write. Expired leases are reaped first; then each free slot below
|
|
687
|
+
* `maxParallel` is tried (from a random offset, so concurrent claimers
|
|
688
|
+
* spread out): the unique index refuses a slot another claim took a moment
|
|
689
|
+
* earlier, and the next one is tried. Partitions already started come
|
|
690
|
+
* first, then the largest.
|
|
691
|
+
*
|
|
692
|
+
* Returns `{ partition, lease }`, `{ busy: true, retryAfterMs }` when every
|
|
693
|
+
* slot is taken, or `{ exhausted: true }` when no partition is left to claim.
|
|
694
|
+
*/
|
|
695
|
+
async claim(name, { generation, plan, maxParallel, ttlMs, owner, shardConcurrency, onReaped }) {
|
|
696
|
+
const reaped = await this.reap(name);
|
|
697
|
+
if (reaped > 0) onReaped?.(reaped);
|
|
698
|
+
const occupied = new Set();
|
|
699
|
+
const perGroup = new Map();
|
|
700
|
+
const leased = await this.#partitions
|
|
701
|
+
.find(
|
|
702
|
+
{ background: name, 'lease.slot': { $exists: true } },
|
|
703
|
+
{ projection: { 'lease.slot': 1, group: 1 }, ...READ_OPTIONS },
|
|
704
|
+
)
|
|
705
|
+
.toArray();
|
|
706
|
+
for (const doc of leased) {
|
|
707
|
+
occupied.add(doc.lease.slot);
|
|
708
|
+
if (doc.group !== undefined) perGroup.set(doc.group, (perGroup.get(doc.group) ?? 0) + 1);
|
|
709
|
+
}
|
|
710
|
+
// Sharded: a group (shard) with every one of its slots held is not claimed from.
|
|
711
|
+
const fullGroups = [];
|
|
712
|
+
if (shardConcurrency !== undefined) {
|
|
713
|
+
for (const [group, held] of perGroup) if (held >= shardConcurrency) fullGroups.push(group);
|
|
714
|
+
}
|
|
715
|
+
const free = [];
|
|
716
|
+
for (let slot = 0; slot < maxParallel; slot++) if (!occupied.has(slot)) free.push(slot);
|
|
717
|
+
if (free.length === 0) return { busy: true, retryAfterMs: Math.max(1, Math.floor(ttlMs / 2)) };
|
|
718
|
+
|
|
719
|
+
const offset = Math.floor(Math.random() * free.length);
|
|
720
|
+
for (let i = 0; i < free.length; i++) {
|
|
721
|
+
const slot = free[(offset + i) % free.length];
|
|
722
|
+
const token = randomId();
|
|
723
|
+
const lease = {
|
|
724
|
+
slot,
|
|
725
|
+
token: literal(token),
|
|
726
|
+
owner: literal(owner ?? token),
|
|
727
|
+
host: literal(os.hostname()),
|
|
728
|
+
pid: literal(process.pid),
|
|
729
|
+
ttlMs: literal(ttlMs),
|
|
730
|
+
renewedAt: '$$NOW',
|
|
731
|
+
claimedAt: '$$NOW',
|
|
732
|
+
};
|
|
733
|
+
let partition;
|
|
734
|
+
try {
|
|
735
|
+
partition = await this.#claimOne(name, {
|
|
736
|
+
generation,
|
|
737
|
+
plan,
|
|
738
|
+
lease,
|
|
739
|
+
shardConcurrency,
|
|
740
|
+
fullGroups,
|
|
741
|
+
});
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (error?.code === DUPLICATE_KEY) continue;
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
if (partition === null) return { exhausted: true };
|
|
747
|
+
return { partition, lease: this.lease(partition._id, token, ttlMs) };
|
|
748
|
+
}
|
|
749
|
+
// Every free slot was taken by a concurrent claim.
|
|
750
|
+
return { busy: true, retryAfterMs: Math.max(1, Math.floor(ttlMs / 4)) };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** One claim attempt for one slot (and, sharded, one per-group slot) */
|
|
754
|
+
async #claimOne(name, { generation, plan, lease, shardConcurrency, fullGroups = [] }) {
|
|
755
|
+
const filter = {
|
|
756
|
+
background: name,
|
|
757
|
+
generation,
|
|
758
|
+
plan,
|
|
759
|
+
status: { $in: OPEN_PARTITION },
|
|
760
|
+
lease: { $exists: false },
|
|
761
|
+
...(fullGroups.length > 0 ? { group: { $nin: fullGroups } } : {}),
|
|
762
|
+
};
|
|
763
|
+
const set = {
|
|
764
|
+
lease,
|
|
765
|
+
status: 'running',
|
|
766
|
+
claims: plus('claims', 1),
|
|
767
|
+
startedAt: { $ifNull: ['$startedAt', '$$NOW'] },
|
|
768
|
+
updatedAt: '$$NOW',
|
|
769
|
+
};
|
|
770
|
+
const sort = { status: -1, seq: 1 };
|
|
771
|
+
if (shardConcurrency === undefined) {
|
|
772
|
+
return this.#partitions.findOneAndUpdate(filter, [{ $set: set }], {
|
|
773
|
+
sort,
|
|
774
|
+
returnDocument: 'after',
|
|
775
|
+
...READ_OPTIONS,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
// Per group (shard): a second unique slot, `<group>#<k>`. The candidate is
|
|
779
|
+
// picked first, so a group found full is known by name — and left out of
|
|
780
|
+
// the next pick, rather than ending the claim as busy. Each round either
|
|
781
|
+
// claims, finds the candidate taken, or rules out one more group.
|
|
782
|
+
const full = new Set(fullGroups);
|
|
783
|
+
let ungroupedFull = false;
|
|
784
|
+
for (;;) {
|
|
785
|
+
const pick = { ...filter };
|
|
786
|
+
if (full.size > 0) pick.group = { $nin: [...full] };
|
|
787
|
+
// Partitions without a group share one set of group slots (`#k`).
|
|
788
|
+
if (ungroupedFull) pick.group = { ...pick.group, $exists: true };
|
|
789
|
+
const candidate = await this.#partitions.findOne(pick, {
|
|
790
|
+
sort,
|
|
791
|
+
projection: { group: 1 },
|
|
792
|
+
...READ_OPTIONS,
|
|
793
|
+
});
|
|
794
|
+
if (candidate === null) return null;
|
|
795
|
+
let taken = false;
|
|
796
|
+
for (let k = 0; k < shardConcurrency && !taken; k++) {
|
|
797
|
+
try {
|
|
798
|
+
const claimed = await this.#partitions.findOneAndUpdate(
|
|
799
|
+
{ ...filter, _id: candidate._id },
|
|
800
|
+
[{ $set: { ...set, lease: { ...lease, groupSlot: `${candidate.group ?? ''}#${k}` } } }],
|
|
801
|
+
{ returnDocument: 'after', ...READ_OPTIONS },
|
|
802
|
+
);
|
|
803
|
+
if (claimed !== null) return claimed;
|
|
804
|
+
// Claimed by someone else between the pick and the write: pick again.
|
|
805
|
+
taken = true;
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (error?.code !== DUPLICATE_KEY || /lease_slot/.test(String(error?.message))) {
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (taken) continue;
|
|
813
|
+
if (candidate.group === undefined) ungroupedFull = true;
|
|
814
|
+
else full.add(candidate.group);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* A lease as a lock: what `runWithLock` heartbeats. `acquire` is a no-op —
|
|
820
|
+
* the claim took it — and `renew` skips the write when a checkpoint
|
|
821
|
+
* renewed it less than a quarter TTL ago (a checkpoint renews too; inside a
|
|
822
|
+
* transaction an extra write to the partition would be a write conflict).
|
|
823
|
+
*/
|
|
824
|
+
lease(partitionId, token, ttlMs) {
|
|
825
|
+
const partitions = this.#partitions;
|
|
826
|
+
let touchedAt = Date.now();
|
|
827
|
+
return {
|
|
828
|
+
label: 'partition lease',
|
|
829
|
+
token,
|
|
830
|
+
ttlMs,
|
|
831
|
+
partitionId,
|
|
832
|
+
touch() {
|
|
833
|
+
touchedAt = Date.now();
|
|
834
|
+
},
|
|
835
|
+
async acquire() {},
|
|
836
|
+
async renew() {
|
|
837
|
+
if (Date.now() - touchedAt < ttlMs / 4) return true;
|
|
838
|
+
const result = await partitions.updateOne({ _id: partitionId, 'lease.token': token }, [
|
|
839
|
+
{ $set: { 'lease.renewedAt': '$$NOW' } },
|
|
840
|
+
]);
|
|
841
|
+
if (result.matchedCount === 1) touchedAt = Date.now();
|
|
842
|
+
return result.matchedCount === 1;
|
|
843
|
+
},
|
|
844
|
+
async release() {
|
|
845
|
+
await partitions.updateOne(
|
|
846
|
+
{ _id: partitionId, 'lease.token': token },
|
|
847
|
+
{ $unset: { lease: '' }, $set: { updatedAt: new Date() } },
|
|
848
|
+
);
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The fenced checkpoint after a batch: only the lease holder's write lands
|
|
855
|
+
* (`lease.token`), on a partition still running. It moves the cursor, adds
|
|
856
|
+
* the batch's counters, keeps new bad ids and document errors, renews the
|
|
857
|
+
* lease — and, with `done`, closes the partition and frees its slot.
|
|
858
|
+
*
|
|
859
|
+
* @throws {LockLostError} (`lease: true`) when the lease is no longer held
|
|
860
|
+
*/
|
|
861
|
+
async checkpoint(lease, update, { session } = {}) {
|
|
862
|
+
const { cursor, counters = {}, badIds = [], docErrors = [], done = false, throttle } = update;
|
|
863
|
+
// A checkpoint is progress: `maxSliceFailures` counts failed slices in a
|
|
864
|
+
// row, so a transient error now and then never adds up to a failure.
|
|
865
|
+
const set = { 'lease.renewedAt': '$$NOW', updatedAt: '$$NOW', failures: literal(0) };
|
|
866
|
+
if (cursor !== undefined) set.cursor = literal(cursor);
|
|
867
|
+
for (const [key, value] of Object.entries(counters)) {
|
|
868
|
+
if (value) set[`counters.${key}`] = plus(`counters.${key}`, value);
|
|
869
|
+
}
|
|
870
|
+
if (badIds.length > 0) {
|
|
871
|
+
set.badIds = {
|
|
872
|
+
$slice: [{ $setUnion: [{ $ifNull: ['$badIds', []] }, literal(badIds)] }, MAX_BAD_IDS + 1],
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
if (docErrors.length > 0) {
|
|
876
|
+
set.lastDocErrors = appendSliced('lastDocErrors', docErrors, MAX_DOC_ERRORS);
|
|
877
|
+
}
|
|
878
|
+
if (throttle !== undefined) set.throttle = literal(throttle);
|
|
879
|
+
const pipeline = [{ $set: set }];
|
|
880
|
+
if (done) {
|
|
881
|
+
pipeline.push({ $set: { status: 'done', doneAt: '$$NOW' } }, { $unset: 'lease' });
|
|
882
|
+
}
|
|
883
|
+
const result = await this.#partitions.updateOne(
|
|
884
|
+
{ _id: lease.partitionId, 'lease.token': lease.token, status: 'running' },
|
|
885
|
+
pipeline,
|
|
886
|
+
session ? { session } : {},
|
|
887
|
+
);
|
|
888
|
+
if (result.matchedCount !== 1) {
|
|
889
|
+
throw new LockLostError('Lost the partition lease mid-slice', {
|
|
890
|
+
lease: true,
|
|
891
|
+
partition: String(lease.partitionId),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
// Inside a transaction the renewal lands only with the commit: the caller
|
|
895
|
+
// touches the lease then — a failed commit must not skip the heartbeat.
|
|
896
|
+
if (session === undefined) lease.touch();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* A failed slice: counted on the partition — which fails for good once it
|
|
901
|
+
* reaches `maxSliceFailures` — and the lease released. Returns whether the
|
|
902
|
+
* partition failed.
|
|
903
|
+
*/
|
|
904
|
+
async failSlice(lease, { error, maxSliceFailures }) {
|
|
905
|
+
// The lease is usually released by the time the failure is counted
|
|
906
|
+
// (runWithLock's finally); a lease of another lane, though, means this
|
|
907
|
+
// one was fenced off — its failure is not the partition's.
|
|
908
|
+
const doc = await this.#partitions.findOneAndUpdate(
|
|
909
|
+
{
|
|
910
|
+
_id: lease.partitionId,
|
|
911
|
+
status: { $in: OPEN_PARTITION },
|
|
912
|
+
$or: [{ 'lease.token': lease.token }, { lease: { $exists: false } }],
|
|
913
|
+
},
|
|
914
|
+
[
|
|
915
|
+
{ $set: { failures: plus('failures', 1), lastError: literal(error), updatedAt: '$$NOW' } },
|
|
916
|
+
{
|
|
917
|
+
$set: {
|
|
918
|
+
status: { $cond: [{ $gte: ['$failures', maxSliceFailures] }, 'failed', '$status'] },
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
{ $unset: 'lease' },
|
|
922
|
+
],
|
|
923
|
+
{ returnDocument: 'after', ...READ_OPTIONS },
|
|
924
|
+
);
|
|
925
|
+
return doc?.status === 'failed';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Fail a partition at once (a document error beyond the budget) and free its slot */
|
|
929
|
+
async failPartition(lease, { error }) {
|
|
930
|
+
await this.#partitions.updateOne(
|
|
931
|
+
{
|
|
932
|
+
_id: lease.partitionId,
|
|
933
|
+
// Not one another lane has finished since (done, its lease gone).
|
|
934
|
+
status: { $in: OPEN_PARTITION },
|
|
935
|
+
$or: [{ 'lease.token': lease.token }, { lease: { $exists: false } }],
|
|
936
|
+
},
|
|
937
|
+
[
|
|
938
|
+
{ $set: { status: 'failed', lastError: literal(error), updatedAt: '$$NOW' } },
|
|
939
|
+
{ $unset: 'lease' },
|
|
940
|
+
],
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
module.exports = {
|
|
946
|
+
BackgroundStore,
|
|
947
|
+
MAX_BAD_IDS,
|
|
948
|
+
MAX_DOC_ERRORS,
|
|
949
|
+
OPEN_PARTITION,
|
|
950
|
+
STATE_SCHEMA,
|
|
951
|
+
};
|