@procedures/mongo 0.48.1 → 0.49.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 +39 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/database-changes.d.ts +15 -2
- package/dist/storage/database-changes.d.ts.map +1 -1
- package/dist/storage/database-changes.js +30 -5
- package/dist/storage/database-changes.js.map +1 -1
- package/dist/storage/document-storage.d.ts +8 -1
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +128 -39
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +6 -5
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/pool-share.d.ts +24 -0
- package/dist/storage/pool-share.d.ts.map +1 -0
- package/dist/storage/pool-share.js +27 -0
- package/dist/storage/pool-share.js.map +1 -0
- package/dist/storage/split-events.d.ts +57 -0
- package/dist/storage/split-events.d.ts.map +1 -0
- package/dist/storage/split-events.js +94 -0
- package/dist/storage/split-events.js.map +1 -0
- package/dist/storage/storage-hints.d.ts +7 -0
- package/dist/storage/storage-hints.d.ts.map +1 -1
- package/dist/storage/storage-hints.js +41 -7
- package/dist/storage/storage-hints.js.map +1 -1
- package/package.json +7 -4
- package/src/storage/database-changes.ts +44 -7
- package/src/storage/document-storage.ts +159 -55
- package/src/storage/materializer.ts +6 -5
- package/src/storage/pool-share.ts +30 -0
- package/src/storage/split-events.ts +135 -0
- package/src/storage/storage-hints.ts +44 -7
|
@@ -68,6 +68,12 @@
|
|
|
68
68
|
* its lease moves its documents to new numbers, so a reader that passed the old numbers gets
|
|
69
69
|
* it. One case stays on a single server: a process that stops between such a late write and the
|
|
70
70
|
* move of its documents. Then a reader that passed the old numbers does not get that change.
|
|
71
|
+
* - A reader can get a write two times, with the same value or a later value. A full answer takes
|
|
72
|
+
* its mark before it lists the items, so it can hold a write above its mark, and the next delta
|
|
73
|
+
* gives the write again. A reader can also read a late write at its old numbers, and then the move
|
|
74
|
+
* gives the write new numbers and a new version. The second case occurs only for a write that ends
|
|
75
|
+
* after `writeLease`.
|
|
76
|
+
* - A version that stops below a pending number has `held`, so a reader knows to read again.
|
|
71
77
|
* - The storage uses a transaction only with a client: the option `client`, or the default
|
|
72
78
|
* connection when `db` is absent. With `db` and no `client`, each write uses the watermark, also
|
|
73
79
|
* on a replica set.
|
|
@@ -113,10 +119,11 @@ import {
|
|
|
113
119
|
type StorageWriteOptions,
|
|
114
120
|
type VersionConflict,
|
|
115
121
|
} from "@procedures/collections";
|
|
116
|
-
import { optionalMs, toMs, type Duration } from "@procedures/client";
|
|
122
|
+
import { optionalMs, Semaphore, toMs, type Duration } from "@procedures/client";
|
|
117
123
|
import { ensureConnection } from "../connection.js";
|
|
118
124
|
import { noteOwnNumbers, notePrunedIds, ownerOf } from "./own-writes.js";
|
|
119
125
|
import { boundPipeline, cutResults, DEFAULT_PIPELINE_LIMITS, scopePipeline, type PipelineLimits } from "./pipeline-scope.js";
|
|
126
|
+
import { CHANGE_READ_DIVISOR, poolShare } from "./pool-share.js";
|
|
120
127
|
|
|
121
128
|
/** The fields of the storage in a stored document. */
|
|
122
129
|
const KEY = "_key";
|
|
@@ -259,6 +266,13 @@ function isDuplicateKey(error: unknown): boolean {
|
|
|
259
266
|
return code === 11000 || [writeErrors].flat().some((entry) => (entry as { code?: unknown } | null)?.code === 11000);
|
|
260
267
|
}
|
|
261
268
|
|
|
269
|
+
/** The place in an ordered bulk write of the write that a duplicate key stopped, or `undefined` when the error does not give one. */
|
|
270
|
+
function duplicateIndex(error: unknown): number | undefined {
|
|
271
|
+
const { writeErrors } = (error ?? {}) as { writeErrors?: unknown };
|
|
272
|
+
const stopped = [writeErrors].flat().find((entry) => (entry as { code?: unknown } | null)?.code === 11000) as { index?: unknown } | undefined;
|
|
273
|
+
return typeof stopped?.index === "number" ? stopped.index : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
262
276
|
/** A stored document. Its `_id` comes from `storedId`: a text by default, any value but `undefined` and `null` in another form. */
|
|
263
277
|
type Stored = Document;
|
|
264
278
|
|
|
@@ -299,27 +313,70 @@ function hasTransactions(db: Db): Promise<boolean> {
|
|
|
299
313
|
/** The most moves of a late write to new numbers. */
|
|
300
314
|
const MAX_MOVES = 10;
|
|
301
315
|
|
|
316
|
+
/** The ends of the work of one collection. These promises do not reject. */
|
|
317
|
+
interface WorkQueue {
|
|
318
|
+
/** The end of each earlier write and change read. */
|
|
319
|
+
all: Promise<unknown>;
|
|
320
|
+
/** The end of the last write. */
|
|
321
|
+
writes: Promise<unknown>;
|
|
322
|
+
}
|
|
323
|
+
|
|
302
324
|
/**
|
|
303
|
-
* The writes and the change reads of each collection
|
|
304
|
-
*
|
|
325
|
+
* The writes and the change reads of each collection: by the client, then by the names of the database and the
|
|
326
|
+
* collection. A new `Db` object of the same client has the same queue (roadmap CLI-93). A write goes alone, in the
|
|
327
|
+
* order of the calls. A change read goes after each earlier write, next to the other change reads. So a read and a
|
|
328
|
+
* write keep the order of their calls, and a read does not wait for a read (roadmap CLI-91).
|
|
305
329
|
*/
|
|
306
|
-
const QUEUES = new WeakMap<object, Map<string,
|
|
330
|
+
const QUEUES = new WeakMap<object, Map<string, WorkQueue>>();
|
|
331
|
+
|
|
332
|
+
/** The change reads of each client at one time. A burst of reads leaves the pool to the other operations. */
|
|
333
|
+
const READS = new WeakMap<object, Semaphore>();
|
|
307
334
|
|
|
308
|
-
function
|
|
335
|
+
function queueOf(db: Db, name: string): { queues: Map<string, WorkQueue>; collection: string; queue: WorkQueue } {
|
|
309
336
|
const owner = ownerOf(db);
|
|
310
337
|
let queues = QUEUES.get(owner);
|
|
311
338
|
if (!queues) QUEUES.set(owner, (queues = new Map()));
|
|
312
339
|
const collection = `${db.databaseName}\u0000${name}`;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
340
|
+
let queue = queues.get(collection);
|
|
341
|
+
if (!queue) queues.set(collection, (queue = { all: Promise.resolve(), writes: Promise.resolve() }));
|
|
342
|
+
return { queues, collection, queue };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** This function drops the queue of a collection when this end is the last of its work. */
|
|
346
|
+
function dropWhenLast(queues: Map<string, WorkQueue>, collection: string, queue: WorkQueue, end: Promise<unknown>): void {
|
|
347
|
+
void end.then(() => {
|
|
348
|
+
if (queue.all === end && queues.get(collection) === queue) queues.delete(collection);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const settledOf = (run: Promise<unknown>): Promise<unknown> =>
|
|
353
|
+
run.then(
|
|
316
354
|
() => undefined,
|
|
317
355
|
() => undefined
|
|
318
356
|
);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
});
|
|
357
|
+
|
|
358
|
+
/** This function starts a write after each earlier write and change read of the collection. */
|
|
359
|
+
function enqueueWrite<R>(db: Db, name: string, task: () => Promise<R>): Promise<R> {
|
|
360
|
+
const { queues, collection, queue } = queueOf(db, name);
|
|
361
|
+
const run = queue.all.then(task);
|
|
362
|
+
const settled = settledOf(run);
|
|
363
|
+
queue.all = settled;
|
|
364
|
+
queue.writes = settled;
|
|
365
|
+
dropWhenLast(queues, collection, queue, settled);
|
|
366
|
+
return run;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** This function starts a change read after each earlier write of the collection, next to the other change reads. */
|
|
370
|
+
function enqueueRead<R>(db: Db, name: string, task: () => Promise<R>): Promise<R> {
|
|
371
|
+
const { queues, collection, queue } = queueOf(db, name);
|
|
372
|
+
const owner = ownerOf(db);
|
|
373
|
+
let reads = READS.get(owner);
|
|
374
|
+
if (!reads) READS.set(owner, (reads = new Semaphore(poolShare(db, CHANGE_READ_DIVISOR))));
|
|
375
|
+
const permits = reads;
|
|
376
|
+
const run = queue.writes.then(() => permits.withPermit(task));
|
|
377
|
+
const end = Promise.all([queue.all, settledOf(run)]);
|
|
378
|
+
queue.all = end;
|
|
379
|
+
dropWhenLast(queues, collection, queue, end);
|
|
323
380
|
return run;
|
|
324
381
|
}
|
|
325
382
|
|
|
@@ -720,6 +777,14 @@ interface Counter {
|
|
|
720
777
|
epoch: string;
|
|
721
778
|
}
|
|
722
779
|
|
|
780
|
+
/** The watermark of a collection, with the floor of the log of a partition. */
|
|
781
|
+
interface Watermark extends Counter {
|
|
782
|
+
/** The floor of the log of the partition: 0 when the partition has no floor document. */
|
|
783
|
+
floor: number;
|
|
784
|
+
/** True when a pending write holds the watermark below the counter. */
|
|
785
|
+
readonly held: boolean;
|
|
786
|
+
}
|
|
787
|
+
|
|
723
788
|
/**
|
|
724
789
|
* A `CollectionStorage` over MongoDB with flat documents, a change log, versions, queries in the
|
|
725
790
|
* database and partitions (roadmap CLI-24).
|
|
@@ -1100,35 +1165,48 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1100
1165
|
|
|
1101
1166
|
/**
|
|
1102
1167
|
* The watermark of the collection: the highest number below each pending number of a write whose
|
|
1103
|
-
* lease has not ended, and the
|
|
1168
|
+
* lease has not ended, the epoch, and the floor of the partition. Each number up to it is written
|
|
1169
|
+
* or abandoned. One command reads them. Only the first read of a collection writes: it makes the counter.
|
|
1104
1170
|
*/
|
|
1105
|
-
private async watermark(db: Db): Promise<
|
|
1171
|
+
private async watermark(db: Db): Promise<Watermark> {
|
|
1106
1172
|
const counters = db.collection<CounterDoc>(this.countersName);
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
{
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1173
|
+
const floorId = this.floorId();
|
|
1174
|
+
const read = (): Promise<Array<{ _id: string; seq: number; epoch: string; floor?: number; low: number | null }>> =>
|
|
1175
|
+
counters
|
|
1176
|
+
.aggregate<{ _id: string; seq: number; epoch: string; floor?: number; low: number | null }>([
|
|
1177
|
+
{ $match: { _id: { $in: [this.collectionName, floorId] } } },
|
|
1178
|
+
{
|
|
1179
|
+
$project: {
|
|
1180
|
+
seq: 1,
|
|
1181
|
+
epoch: 1,
|
|
1182
|
+
floor: 1,
|
|
1183
|
+
low: {
|
|
1184
|
+
$min: {
|
|
1185
|
+
$map: {
|
|
1186
|
+
input: {
|
|
1187
|
+
$filter: { input: { $ifNull: ["$pending", []] }, cond: { $gt: ["$$this.at", { $subtract: ["$$NOW", this.writeLeaseMs] }] } },
|
|
1188
|
+
},
|
|
1189
|
+
in: "$$this.from",
|
|
1120
1190
|
},
|
|
1121
|
-
in: "$$this.from",
|
|
1122
1191
|
},
|
|
1123
1192
|
},
|
|
1124
1193
|
},
|
|
1125
1194
|
},
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1195
|
+
])
|
|
1196
|
+
.toArray();
|
|
1197
|
+
let docs = await read();
|
|
1198
|
+
if (!docs.some((doc) => doc._id === this.collectionName)) {
|
|
1199
|
+
// A duplicate key: another read made the counter at the same time
|
|
1200
|
+
await counters.updateOne({ _id: this.collectionName }, { $setOnInsert: { seq: 0, epoch: randomUUID() } }, { upsert: true }).catch((error: unknown) => {
|
|
1201
|
+
if (!isDuplicateKey(error)) throw error;
|
|
1202
|
+
});
|
|
1203
|
+
docs = await read();
|
|
1204
|
+
}
|
|
1205
|
+
const mark = docs.find((doc) => doc._id === this.collectionName);
|
|
1129
1206
|
if (!mark) throw new Error(`The sequence of ${this.collectionName} is not available`);
|
|
1130
1207
|
const seq = typeof mark.low === "number" ? Math.min(mark.seq, mark.low - 1) : mark.seq;
|
|
1131
|
-
|
|
1208
|
+
const floor = docs.find((doc) => doc._id === floorId)?.floor;
|
|
1209
|
+
return { seq, epoch: mark.epoch, floor: typeof floor === "number" ? floor : 0, held: seq < mark.seq };
|
|
1132
1210
|
}
|
|
1133
1211
|
|
|
1134
1212
|
/** The id of the document of the oldest kept change of the partition. */
|
|
@@ -1314,19 +1392,23 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1314
1392
|
|
|
1315
1393
|
async getChangesSince(since?: string): Promise<StorageChanges<T>> {
|
|
1316
1394
|
const { db, items } = await this.collection();
|
|
1317
|
-
return
|
|
1395
|
+
return enqueueRead(db, this.collectionName, async () => {
|
|
1318
1396
|
const counter = await this.watermark(db);
|
|
1319
1397
|
const version = `${counter.epoch}.${counter.seq}`;
|
|
1398
|
+
const held = counter.held ? { held: true } : {};
|
|
1320
1399
|
const full = async (): Promise<StorageChanges<T>> => ({
|
|
1321
1400
|
version,
|
|
1322
1401
|
full: true,
|
|
1323
1402
|
changed: this.decodeAll(await items.find(this.liveFilter()).sort({ [ORD]: 1, _id: 1 }).toArray()),
|
|
1324
1403
|
deleted: [],
|
|
1404
|
+
...held,
|
|
1325
1405
|
});
|
|
1326
1406
|
const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
|
|
1327
1407
|
if (!match || match[1] !== counter.epoch) return full();
|
|
1328
1408
|
const from = Number(match[2]);
|
|
1329
|
-
if (from <
|
|
1409
|
+
if (from < counter.floor || from > counter.seq) return full();
|
|
1410
|
+
// No number is above this version: the read needs no query of the items
|
|
1411
|
+
if (from === counter.seq) return { version, full: false, changed: [], deleted: [], ...held };
|
|
1330
1412
|
// A number above the watermark can belong to a write that is not visible yet: the next read gives it
|
|
1331
1413
|
const docs = await items
|
|
1332
1414
|
.find({ ...this.partitionFilter(), [SEQ]: { $gt: from, $lte: counter.seq } } as Filter<Stored>)
|
|
@@ -1342,7 +1424,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1342
1424
|
if (doc[TOMB] === true) deleted.push(id);
|
|
1343
1425
|
else changed.push(this.decode(doc, id)!);
|
|
1344
1426
|
}
|
|
1345
|
-
return { version, full: false, changed, deleted };
|
|
1427
|
+
return { version, full: false, changed, deleted, ...held };
|
|
1346
1428
|
});
|
|
1347
1429
|
}
|
|
1348
1430
|
|
|
@@ -1352,7 +1434,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1352
1434
|
|
|
1353
1435
|
private async write<R>(task: (db: Db, items: Collection<Stored>) => Promise<R>): Promise<R> {
|
|
1354
1436
|
const { db, items } = await this.collection();
|
|
1355
|
-
return
|
|
1437
|
+
return enqueueWrite(db, this.collectionName, async () => {
|
|
1356
1438
|
// The floor document of the partition, outside each transaction of the task (roadmap O66)
|
|
1357
1439
|
await this.noteFloor(db);
|
|
1358
1440
|
return task(db, items);
|
|
@@ -1380,24 +1462,38 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1380
1462
|
JSON.stringify(value);
|
|
1381
1463
|
}
|
|
1382
1464
|
await this.write(async (db, collection) => {
|
|
1383
|
-
const
|
|
1465
|
+
const ids = items.map(([id]) => id);
|
|
1466
|
+
const places = await this.placesOf(collection, ids);
|
|
1384
1467
|
await this.withNumbers(db, collection, items.length, async (first, session) => {
|
|
1385
1468
|
const guards = await this.checkVersions(collection, expected, session);
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1469
|
+
const operationsAt = (known: ReadonlyMap<string, number>): AnyBulkWriteOperation<Stored>[] => {
|
|
1470
|
+
const placed = new Map(known);
|
|
1471
|
+
return items.map(([id, value], index) => {
|
|
1472
|
+
const seq = first + index;
|
|
1473
|
+
// A new id takes its place at its first write; a later write of the batch keeps it
|
|
1474
|
+
if (!placed.has(id)) placed.set(id, seq);
|
|
1475
|
+
// The guard of an id with a precondition: the write fails when another writer came first
|
|
1476
|
+
const guard = guards.get(id);
|
|
1477
|
+
guards.delete(id);
|
|
1478
|
+
return { replaceOne: { filter: { ...this.writeFilter(id), ...guard }, replacement: this.encode(id, value, seq, placed.get(id)!), upsert: true } };
|
|
1479
|
+
});
|
|
1480
|
+
};
|
|
1481
|
+
// The race of a new id: another process made it after the query of this upsert, so a new try from the
|
|
1482
|
+
// stopped write is a rewrite, with the same number. These numbers stay pending, so no reader passes them.
|
|
1483
|
+
// A real duplicate stops one write two times, and it reaches the caller. So a batch has at most one try more than it has writes.
|
|
1484
|
+
let known = places;
|
|
1485
|
+
for (let from = 0, tries = 0; ; tries++) {
|
|
1486
|
+
try {
|
|
1487
|
+
await collection.bulkWrite(operationsAt(known).slice(from), { ordered: true, ...(session ? { session } : {}) });
|
|
1488
|
+
return;
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
if (session || !isDuplicateKey(error)) throw error;
|
|
1491
|
+
if (expected.length > 0) throw await this.lateConflict(collection, expected);
|
|
1492
|
+
const at = duplicateIndex(error);
|
|
1493
|
+
if (at === undefined || (tries > 0 && at === 0)) throw error;
|
|
1494
|
+
from += at;
|
|
1495
|
+
known = await this.placesOf(collection, ids);
|
|
1496
|
+
}
|
|
1401
1497
|
}
|
|
1402
1498
|
});
|
|
1403
1499
|
});
|
|
@@ -1410,9 +1506,17 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
1410
1506
|
const next = applyPatch(this.decode(doc, id)![1], fields) as T;
|
|
1411
1507
|
// The encoding is checked before a number of the sequence goes
|
|
1412
1508
|
this.flatOf(id, next);
|
|
1413
|
-
await this.withNumbers(db, collection, 1, (first, session) =>
|
|
1414
|
-
|
|
1415
|
-
|
|
1509
|
+
await this.withNumbers(db, collection, 1, async (first, session) => {
|
|
1510
|
+
const write = (): Promise<unknown> =>
|
|
1511
|
+
collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) });
|
|
1512
|
+
try {
|
|
1513
|
+
await write();
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
// The document went after the read, and another process made it again: one new try is a rewrite
|
|
1516
|
+
if (session || !isDuplicateKey(error)) throw error;
|
|
1517
|
+
await write();
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1416
1520
|
return next;
|
|
1417
1521
|
});
|
|
1418
1522
|
}
|
|
@@ -62,6 +62,7 @@ import { BSON, ObjectId, type AnyBulkWriteOperation, type ChangeStream, type Cli
|
|
|
62
62
|
import { collectionChangeTopic, ms, optionalMs, realClock, Semaphore, writeLog, type Cancel, type Clock, type CollectionChangeEvent, type Duration, type LogFields, type Logger } from "@procedures/client";
|
|
63
63
|
import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget, type StorageWriteTarget, type StoredForm } from "./document-storage.js";
|
|
64
64
|
import { scopePipeline } from "./pipeline-scope.js";
|
|
65
|
+
import { poolShare } from "./pool-share.js";
|
|
65
66
|
import type { StorageHintPublisher } from "./storage-hints.js";
|
|
66
67
|
|
|
67
68
|
/** The fields of a storage that a row never keeps: its sequence number and its tombstone flag (roadmap CLI-77). */
|
|
@@ -319,9 +320,7 @@ const MOST_PASSES = 32;
|
|
|
319
320
|
* a time, so two thirds of the pool stay free for the other operations of the process.
|
|
320
321
|
*/
|
|
321
322
|
function passLimit(db: Db): number {
|
|
322
|
-
|
|
323
|
-
const pool = (db.client as Db["client"] | undefined)?.options.maxPoolSize;
|
|
324
|
-
return pool ? Math.max(1, Math.min(MOST_PASSES, Math.floor(pool / 3))) : MOST_PASSES;
|
|
323
|
+
return poolShare(db, 3, MOST_PASSES);
|
|
325
324
|
}
|
|
326
325
|
|
|
327
326
|
/** The `_id` of a row in the result, from the stored `_id` of `partitionTargetId`. */
|
|
@@ -792,7 +791,8 @@ export class ViewMaterializer {
|
|
|
792
791
|
void this.track(this.onViewChange(change)).catch((error: unknown) => this.log("error", "A change of a view failed", { collection: this.viewsName, operationType: change["operationType"], error }, `a change of a view failed: ${String((error as Error)?.message ?? error)}`));
|
|
793
792
|
});
|
|
794
793
|
stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
|
|
795
|
-
|
|
794
|
+
// The cursor of the driver tells its close before the error of a failed read. The close waits one turn, so the log gives the error (roadmap CLI-107)
|
|
795
|
+
stream.on("close", () => setImmediate(() => rewatch("closed")));
|
|
796
796
|
return openOnServer(stream);
|
|
797
797
|
}
|
|
798
798
|
|
|
@@ -1024,7 +1024,8 @@ export class ViewMaterializer {
|
|
|
1024
1024
|
if (watch.stream === stream) this.route(change, watch);
|
|
1025
1025
|
});
|
|
1026
1026
|
stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
|
|
1027
|
-
|
|
1027
|
+
// The cursor of the driver tells its close before the error of a failed read. The close waits one turn, so the log gives the error (roadmap CLI-107)
|
|
1028
|
+
stream.on("close", () => setImmediate(() => rewatch("closed")));
|
|
1028
1029
|
return openOnServer(stream);
|
|
1029
1030
|
}
|
|
1030
1031
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Db } from "mongodb";
|
|
2
|
+
|
|
3
|
+
/** The pool size of the driver when the client does not tell its size (the default of `maxPoolSize`). */
|
|
4
|
+
export const DEFAULT_POOL_SIZE = 100;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The part of the pool for the change reads of one client: one twelfth, so 8 reads at the default pool.
|
|
8
|
+
* A burst of change reads waits on round trips over a network. So a share of the pool, not a fixed count, sets the limit.
|
|
9
|
+
*
|
|
10
|
+
* The objective: in a burst, the p95 of other queries of the pool stays within 1.5 round trips.
|
|
11
|
+
* The share of the pool controls that p95 only when the connections wait on the network.
|
|
12
|
+
* So the suite `read-bound` of `stream-cost.yml` gates the objective at a delay of 20 ms.
|
|
13
|
+
* There, 8 reads at one time gave 1.01 to 1.44 round trips (run 42105731536). 16 reads gave up to 1.86, and 4 reads gave a burst twice as long.
|
|
14
|
+
*
|
|
15
|
+
* At a short round trip, the work of the server sets the p95 of each client of the server.
|
|
16
|
+
* A limit for one pool only spreads that work. At 5 ms, 8 reads gave up to 3 round trips, and 4 reads gave 2.2 (run 42041953313).
|
|
17
|
+
* There the suite only reports, with the p95 over the quiet p95 of the same probe.
|
|
18
|
+
*/
|
|
19
|
+
export const CHANGE_READ_DIVISOR = 12;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* This function gives the limit of operations of one kind at one time on a pool: the pool size over `divisor`,
|
|
23
|
+
* from 1 to `most`. Each operation uses one connection at a time. So the other operations of the process find a free
|
|
24
|
+
* connection in a burst, and a larger pool gives a larger limit.
|
|
25
|
+
*/
|
|
26
|
+
export function poolShare(db: Db, divisor: number, most: number = Number.POSITIVE_INFINITY): number {
|
|
27
|
+
// A fake database of a test has no client. A pool size of 0 has no limit in the driver: the share then uses the default size
|
|
28
|
+
const pool = (db.client as Db["client"] | undefined)?.options.maxPoolSize || DEFAULT_POOL_SIZE;
|
|
29
|
+
return Math.max(1, Math.min(most, Math.floor(pool / divisor)));
|
|
30
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change events over the BSON limit of 16 MiB (roadmap CLI-107). An event holds the images of
|
|
3
|
+
* the document before and after its write. Thus a write of a small field can make an event over the
|
|
4
|
+
* limit. The server then fails the read of that event, and a stream that opens again at the same
|
|
5
|
+
* token fails again.
|
|
6
|
+
*
|
|
7
|
+
* - The stage `$changeStreamSplitLargeEvent` (MongoDB 7.0, and 6.0.9 or later) splits such an event
|
|
8
|
+
* into fragments. Each fragment has its own `_id` and the field `splitEvent: { fragment, of }`.
|
|
9
|
+
* The stage is the last stage of the pipeline.
|
|
10
|
+
* - `eventJoiner()` joins the fragments of one event into one event. The joined event has the `_id`
|
|
11
|
+
* of its last fragment, and no field `splitEvent`.
|
|
12
|
+
* - A stream that starts after a fragment gets the fragments after it. The joiner then tells that the
|
|
13
|
+
* first fragments are missing.
|
|
14
|
+
* - `sameTokenFaults()` counts the errors with one code at one resume token. A fault that stays at one
|
|
15
|
+
* event comes again at each open. A network error and an error that the driver resumes do not count.
|
|
16
|
+
*/
|
|
17
|
+
import type { Document } from "mongodb";
|
|
18
|
+
import { codeOf } from "./storage-hints.js";
|
|
19
|
+
|
|
20
|
+
/** The stage that splits a change event over the BSON limit into fragments. It is the last stage of a pipeline. */
|
|
21
|
+
export const SPLIT_LARGE_EVENT_STAGE: Readonly<Document> = Object.freeze({ $changeStreamSplitLargeEvent: {} });
|
|
22
|
+
|
|
23
|
+
/** The code of a server that does not know a stage of the pipeline: a server before 6.0.9 does not know the split stage. */
|
|
24
|
+
export const UNKNOWN_STAGE_CODE = 40324;
|
|
25
|
+
|
|
26
|
+
/** The code of a BSON object over the limit: the server cannot give the event. */
|
|
27
|
+
export const OBJECT_TOO_LARGE_CODE = 10334;
|
|
28
|
+
|
|
29
|
+
/** The count of errors with one code at one token after which the fault does not go away. */
|
|
30
|
+
export const SAME_TOKEN_LIMIT = 5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The codes of the errors that a getMore of a change stream gives in an election or a network fault.
|
|
34
|
+
* The driver resumes the stream after these codes, and the server gives them at each token.
|
|
35
|
+
*/
|
|
36
|
+
const RESUMABLE_CODES: ReadonlySet<number> = new Set([6, 7, 63, 89, 91, 133, 150, 189, 234, 262, 9001, 10107, 11600, 11602, 13388, 13435, 13436]);
|
|
37
|
+
|
|
38
|
+
/** The result of one event of the stream: a whole event, a fragment that waits for the others, or an event without its first fragments. */
|
|
39
|
+
export type JoinResult = { kind: "event"; change: Document } | { kind: "part" } | { kind: "missing"; change: Document; missing: number };
|
|
40
|
+
|
|
41
|
+
/** The joiner of the fragments of a split event. */
|
|
42
|
+
export interface EventJoiner {
|
|
43
|
+
/** This method takes the next event of the stream, and gives the event when it is whole. */
|
|
44
|
+
push(change: Document): JoinResult;
|
|
45
|
+
/** True while fragments of an event wait for the others: the stream is then in the middle of an event. */
|
|
46
|
+
readonly partial: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The fragment fields of an event, or undefined for an event that is not split. */
|
|
50
|
+
function splitOf(change: Document): { fragment: number; of: number } | undefined {
|
|
51
|
+
const split: unknown = change["splitEvent"];
|
|
52
|
+
if (typeof split !== "object" || split === null) return undefined;
|
|
53
|
+
const { fragment, of } = split as Document;
|
|
54
|
+
return typeof fragment === "number" && typeof of === "number" ? { fragment, of } : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** This function joins fragments into one event: the fields of each fragment, the `_id` of the last, and no `splitEvent`. */
|
|
58
|
+
function joined(parts: readonly Document[]): Document {
|
|
59
|
+
const event: Document = Object.assign({}, ...parts);
|
|
60
|
+
event["_id"] = parts[parts.length - 1]!["_id"];
|
|
61
|
+
delete event["splitEvent"];
|
|
62
|
+
return event;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** This function gives a new joiner. Use one joiner for each stream: a new stream starts with no fragments. */
|
|
66
|
+
export function eventJoiner(): EventJoiner {
|
|
67
|
+
let parts: Document[] = [];
|
|
68
|
+
/** The fragments that the stream did not give: the fragments before the first fragment that came. */
|
|
69
|
+
let missing = 0;
|
|
70
|
+
return {
|
|
71
|
+
get partial() {
|
|
72
|
+
return parts.length > 0;
|
|
73
|
+
},
|
|
74
|
+
push(change) {
|
|
75
|
+
const split = splitOf(change);
|
|
76
|
+
if (split === undefined) {
|
|
77
|
+
// An event that is not split ends fragments that wait: the stream cannot give them again
|
|
78
|
+
parts = [];
|
|
79
|
+
missing = 0;
|
|
80
|
+
return { kind: "event", change };
|
|
81
|
+
}
|
|
82
|
+
if (parts.length === 0 || split.fragment !== parts.length + missing + 1) {
|
|
83
|
+
parts = [];
|
|
84
|
+
missing = split.fragment - 1;
|
|
85
|
+
}
|
|
86
|
+
parts.push(change);
|
|
87
|
+
if (split.fragment < split.of) return { kind: "part" };
|
|
88
|
+
const event = joined(parts);
|
|
89
|
+
const lost = missing;
|
|
90
|
+
parts = [];
|
|
91
|
+
missing = 0;
|
|
92
|
+
if (lost > 0) return { kind: "missing", change: { ...event, splitEvent: { missing: lost, of: split.of } }, missing: lost };
|
|
93
|
+
return { kind: "event", change: event };
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True for an error that the server gives at each token in an election or a network fault. */
|
|
99
|
+
export function isResumable(error: unknown): boolean {
|
|
100
|
+
const labels = (error as { errorLabels?: unknown } | undefined)?.errorLabels;
|
|
101
|
+
if (Array.isArray(labels) && labels.includes("ResumableChangeStreamError")) return true;
|
|
102
|
+
const code = codeOf(error);
|
|
103
|
+
return code === undefined || RESUMABLE_CODES.has(code);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The counter of the errors with one code at one token. */
|
|
107
|
+
export interface SameTokenFaults {
|
|
108
|
+
/** This method counts an error at a token, and gives the count of errors in a row with its code at that token. A resumable error gives 0. */
|
|
109
|
+
count(error: unknown, token: unknown): number;
|
|
110
|
+
/** This method starts the count again: the stream passed the token. */
|
|
111
|
+
reset(): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** This function gives a new counter of the errors with one code at one token. */
|
|
115
|
+
export function sameTokenFaults(): SameTokenFaults {
|
|
116
|
+
let key: string | undefined;
|
|
117
|
+
let repeats = 0;
|
|
118
|
+
return {
|
|
119
|
+
count(error, token) {
|
|
120
|
+
if (isResumable(error)) {
|
|
121
|
+
key = undefined;
|
|
122
|
+
repeats = 0;
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
const next = `${String(codeOf(error))} ${JSON.stringify(token ?? null)}`;
|
|
126
|
+
repeats = next === key ? repeats + 1 : 1;
|
|
127
|
+
key = next;
|
|
128
|
+
return repeats;
|
|
129
|
+
},
|
|
130
|
+
reset() {
|
|
131
|
+
key = undefined;
|
|
132
|
+
repeats = 0;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
type RawStorageWrite,
|
|
93
93
|
type StoredForm,
|
|
94
94
|
} from "./document-storage.js";
|
|
95
|
+
import { SAME_TOKEN_LIMIT, SPLIT_LARGE_EVENT_STAGE, UNKNOWN_STAGE_CODE, eventJoiner, sameTokenFaults } from "./split-events.js";
|
|
95
96
|
import { trackOwnNumbers, type OwnNumbers } from "./own-writes.js";
|
|
96
97
|
|
|
97
98
|
/** The change of one item of a change event of a `MongoDocumentStorage` collection, from `storageChangeOf()`. */
|
|
@@ -197,6 +198,13 @@ export interface WatchStorageChangesOptions {
|
|
|
197
198
|
* @default { initialDelay: ms(250), maxDelay: seconds(30), backoffMultiplier: 2, jitter: 1 }
|
|
198
199
|
*/
|
|
199
200
|
backoff?: ResubscribeBackoff | undefined;
|
|
201
|
+
/**
|
|
202
|
+
* True to end the pipeline of the stream with the stage `$changeStreamSplitLargeEvent`. The watcher
|
|
203
|
+
* then joins the fragments of an event over 16 MiB. With false, such an event fails at each open,
|
|
204
|
+
* and after 5 failures at its token the subscribers get a gap.
|
|
205
|
+
* @default true
|
|
206
|
+
*/
|
|
207
|
+
splitLargeEvents?: boolean | undefined;
|
|
200
208
|
/** The clock of the timers and of the time limit of the own writes (default the clock of the platform). The timers do not keep a process alive. */
|
|
201
209
|
clock?: Clock | undefined;
|
|
202
210
|
/**
|
|
@@ -513,6 +521,9 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
513
521
|
const unplaced = new Set<string>();
|
|
514
522
|
/** The partitions that got a hint: the topics of a gap. */
|
|
515
523
|
const partitions = new Set<string | undefined>();
|
|
524
|
+
/** True while the pipeline ends with the split stage. A server that does not know the stage sets it to false. */
|
|
525
|
+
let split = options.splitLargeEvents !== false;
|
|
526
|
+
const faults = sameTokenFaults();
|
|
516
527
|
let markReady: () => void = () => undefined;
|
|
517
528
|
let failReady: (error: unknown) => void = () => undefined;
|
|
518
529
|
const ready = new Promise<void>((resolve, reject) => {
|
|
@@ -827,6 +838,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
827
838
|
const onChange = (change: Document): void => {
|
|
828
839
|
token = change["_id"];
|
|
829
840
|
attempt = 0;
|
|
841
|
+
faults.reset();
|
|
830
842
|
const type = change["operationType"];
|
|
831
843
|
if (type === "drop" || type === "rename" || type === "dropDatabase") return gap(`the collection ${source.collection} had a ${String(type)}`, { operationType: type, resumeToken: true });
|
|
832
844
|
const ns = asDocument(change["ns"]);
|
|
@@ -917,7 +929,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
917
929
|
if (token === undefined && opened) gap(`the stream of ${source.collection} opens again without a resume token`, { resumeToken: false });
|
|
918
930
|
let next: ChangeStream;
|
|
919
931
|
try {
|
|
920
|
-
next = db.collection(source.collection).watch([], {
|
|
932
|
+
next = db.collection(source.collection).watch(split ? [SPLIT_LARGE_EVENT_STAGE] : [], {
|
|
921
933
|
fullDocument: "updateLookup",
|
|
922
934
|
fullDocumentBeforeChange: "whenAvailable",
|
|
923
935
|
...(token !== undefined && { startAfter: token }),
|
|
@@ -927,6 +939,9 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
927
939
|
return schedule(`the stream cannot open (${messageOf(error)})`, error);
|
|
928
940
|
}
|
|
929
941
|
stream = next;
|
|
942
|
+
const joiner = eventJoiner();
|
|
943
|
+
/** The token at the open: a reopen at the same token is no success, so the backoff does not start again. */
|
|
944
|
+
const openToken = JSON.stringify(token ?? null);
|
|
930
945
|
let ended = false;
|
|
931
946
|
const end = (reason: string, error?: unknown): void => {
|
|
932
947
|
if (ended) return;
|
|
@@ -942,13 +957,27 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
942
957
|
log("warn", "The server does not know the resume token", { collection: source.collection, code, resumeToken: true }, `the server does not know the resume token of ${source.collection} (code ${code})`);
|
|
943
958
|
token = undefined;
|
|
944
959
|
}
|
|
960
|
+
if (code === UNKNOWN_STAGE_CODE && split) {
|
|
961
|
+
// A server before 6.0.9: the stream opens again without the split stage
|
|
962
|
+
split = false;
|
|
963
|
+
log("warn", "The server does not know the stage $changeStreamSplitLargeEvent", { collection: source.collection, code }, `the server does not know the stage $changeStreamSplitLargeEvent: an event of ${source.collection} over 16 MiB gives a gap`);
|
|
964
|
+
} else if (error !== undefined) {
|
|
965
|
+
const repeats = faults.count(error, token);
|
|
966
|
+
if (repeats >= SAME_TOKEN_LIMIT) {
|
|
967
|
+
// The fault stays at one event: the next stream starts now without the token, and its open gives the gap
|
|
968
|
+
faults.reset();
|
|
969
|
+
token = undefined;
|
|
970
|
+
log("warn", "The stream fails at one token: it opens again without the token", { collection: source.collection, code, repeats }, `the stream of ${source.collection} failed ${repeats} times at one token with the code ${String(code)}: it opens again without the token`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
945
973
|
schedule(`the stream ${reason}`, error);
|
|
946
974
|
};
|
|
947
975
|
next.on("resumeTokenChanged", (resumeToken: unknown) => {
|
|
948
976
|
if (resumeToken === undefined || resumeToken === null) return;
|
|
949
|
-
token
|
|
977
|
+
// In the middle of a split event, the token stays before the event: a stream that opens again gets all its fragments
|
|
978
|
+
if (!joiner.partial) token = resumeToken;
|
|
950
979
|
opened = true;
|
|
951
|
-
attempt = 0;
|
|
980
|
+
if (JSON.stringify(resumeToken) !== openToken) attempt = 0;
|
|
952
981
|
markReady();
|
|
953
982
|
live = true;
|
|
954
983
|
afterQueue();
|
|
@@ -958,15 +987,23 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
|
|
|
958
987
|
markReady();
|
|
959
988
|
live = true;
|
|
960
989
|
afterQueue();
|
|
961
|
-
|
|
990
|
+
const result = joiner.push(change);
|
|
991
|
+
if (result.kind === "part") return;
|
|
992
|
+
if (result.kind === "missing") {
|
|
993
|
+
// The stream started after a fragment: the event cannot be read, so the subscribers read the collection in full
|
|
994
|
+
token = result.change["_id"];
|
|
995
|
+
return gap(`a split event of ${source.collection} came without its first ${result.missing} fragments`, { missing: result.missing, resumeToken: true });
|
|
996
|
+
}
|
|
997
|
+
if (result.change["operationType"] === "invalidate") {
|
|
962
998
|
// The collection was dropped or renamed: the stream starts after the invalidate event
|
|
963
|
-
token = change["_id"];
|
|
999
|
+
token = result.change["_id"];
|
|
964
1000
|
return end("ended (invalidate)");
|
|
965
1001
|
}
|
|
966
|
-
onChange(change);
|
|
1002
|
+
onChange(result.change);
|
|
967
1003
|
});
|
|
968
1004
|
next.on("error", (error: Error) => end(`failed: ${error.message}`, error));
|
|
969
|
-
|
|
1005
|
+
// The cursor of the driver tells its close before the error of a failed read. The end waits one turn, so the error comes first and counts
|
|
1006
|
+
next.on("close", () => setImmediate(() => end("closed")));
|
|
970
1007
|
};
|
|
971
1008
|
|
|
972
1009
|
void open();
|