@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.
@@ -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, one at a time: by the client, then by the names of the
304
- * database and the collection. A new `Db` object of the same client has the same queue (roadmap CLI-93).
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, Promise<unknown>>>();
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 enqueue<R>(db: Db, name: string, task: () => Promise<R>): Promise<R> {
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
- const before = queues.get(collection) ?? Promise.resolve();
314
- const run = before.then(task, task);
315
- const settled = run.then(
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
- queues.set(collection, settled);
320
- void settled.then(() => {
321
- if (queues.get(collection) === settled) queues.delete(collection);
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 epoch. Each number up to it is written or abandoned.
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<Counter> {
1171
+ private async watermark(db: Db): Promise<Watermark> {
1106
1172
  const counters = db.collection<CounterDoc>(this.countersName);
1107
- await counters.updateOne({ _id: this.collectionName }, { $setOnInsert: { seq: 0, epoch: randomUUID() } }, { upsert: true });
1108
- const [mark] = await counters
1109
- .aggregate<{ seq: number; epoch: string; low: number | null }>([
1110
- { $match: { _id: this.collectionName } },
1111
- {
1112
- $project: {
1113
- seq: 1,
1114
- epoch: 1,
1115
- low: {
1116
- $min: {
1117
- $map: {
1118
- input: {
1119
- $filter: { input: { $ifNull: ["$pending", []] }, cond: { $gt: ["$$this.at", { $subtract: ["$$NOW", this.writeLeaseMs] }] } },
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
- .toArray();
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
- return { seq, epoch: mark.epoch };
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 enqueue(db, this.collectionName, async () => {
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 < (await this.floorOf(db)) || from > counter.seq) return full();
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 enqueue(db, this.collectionName, async () => {
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 places = await this.placesOf(collection, items.map(([id]) => id));
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 placed = new Map(places);
1387
- const operations: AnyBulkWriteOperation<Stored>[] = items.map(([id, value], index) => {
1388
- const seq = first + index;
1389
- // A new id takes its place at its first write; a later write of the batch keeps it
1390
- if (!placed.has(id)) placed.set(id, seq);
1391
- // The guard of an id with a precondition: the write fails when another writer came first
1392
- const guard = guards.get(id);
1393
- guards.delete(id);
1394
- return { replaceOne: { filter: { ...this.writeFilter(id), ...guard }, replacement: this.encode(id, value, seq, placed.get(id)!), upsert: true } };
1395
- });
1396
- try {
1397
- await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
1398
- } catch (error) {
1399
- if (expected.length > 0 && !session && isDuplicateKey(error)) throw await this.lateConflict(collection, expected);
1400
- throw error;
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
- collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) })
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
- // A fake database of a test has no client
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
- stream.on("close", () => rewatch("closed"));
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
- stream.on("close", () => rewatch("closed"));
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 = resumeToken;
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
- if (change["operationType"] === "invalidate") {
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
- next.on("close", () => end("closed"));
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();