@procedures/mongo 0.48.0 → 0.48.2

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.
@@ -259,6 +265,13 @@ function isDuplicateKey(error: unknown): boolean {
259
265
  return code === 11000 || [writeErrors].flat().some((entry) => (entry as { code?: unknown } | null)?.code === 11000);
260
266
  }
261
267
 
268
+ /** The place in an ordered bulk write of the write that a duplicate key stopped, or `undefined` when the error does not give one. */
269
+ function duplicateIndex(error: unknown): number | undefined {
270
+ const { writeErrors } = (error ?? {}) as { writeErrors?: unknown };
271
+ const stopped = [writeErrors].flat().find((entry) => (entry as { code?: unknown } | null)?.code === 11000) as { index?: unknown } | undefined;
272
+ return typeof stopped?.index === "number" ? stopped.index : undefined;
273
+ }
274
+
262
275
  /** A stored document. Its `_id` comes from `storedId`: a text by default, any value but `undefined` and `null` in another form. */
263
276
  type Stored = Document;
264
277
 
@@ -1102,7 +1115,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1102
1115
  * The watermark of the collection: the highest number below each pending number of a write whose
1103
1116
  * lease has not ended, and the epoch. Each number up to it is written or abandoned.
1104
1117
  */
1105
- private async watermark(db: Db): Promise<Counter> {
1118
+ private async watermark(db: Db): Promise<Counter & { readonly held: boolean }> {
1106
1119
  const counters = db.collection<CounterDoc>(this.countersName);
1107
1120
  await counters.updateOne({ _id: this.collectionName }, { $setOnInsert: { seq: 0, epoch: randomUUID() } }, { upsert: true });
1108
1121
  const [mark] = await counters
@@ -1128,7 +1141,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1128
1141
  .toArray();
1129
1142
  if (!mark) throw new Error(`The sequence of ${this.collectionName} is not available`);
1130
1143
  const seq = typeof mark.low === "number" ? Math.min(mark.seq, mark.low - 1) : mark.seq;
1131
- return { seq, epoch: mark.epoch };
1144
+ return { seq, epoch: mark.epoch, held: seq < mark.seq };
1132
1145
  }
1133
1146
 
1134
1147
  /** The id of the document of the oldest kept change of the partition. */
@@ -1317,11 +1330,13 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1317
1330
  return enqueue(db, this.collectionName, async () => {
1318
1331
  const counter = await this.watermark(db);
1319
1332
  const version = `${counter.epoch}.${counter.seq}`;
1333
+ const held = counter.held ? { held: true } : {};
1320
1334
  const full = async (): Promise<StorageChanges<T>> => ({
1321
1335
  version,
1322
1336
  full: true,
1323
1337
  changed: this.decodeAll(await items.find(this.liveFilter()).sort({ [ORD]: 1, _id: 1 }).toArray()),
1324
1338
  deleted: [],
1339
+ ...held,
1325
1340
  });
1326
1341
  const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
1327
1342
  if (!match || match[1] !== counter.epoch) return full();
@@ -1342,7 +1357,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1342
1357
  if (doc[TOMB] === true) deleted.push(id);
1343
1358
  else changed.push(this.decode(doc, id)!);
1344
1359
  }
1345
- return { version, full: false, changed, deleted };
1360
+ return { version, full: false, changed, deleted, ...held };
1346
1361
  });
1347
1362
  }
1348
1363
 
@@ -1380,24 +1395,38 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1380
1395
  JSON.stringify(value);
1381
1396
  }
1382
1397
  await this.write(async (db, collection) => {
1383
- const places = await this.placesOf(collection, items.map(([id]) => id));
1398
+ const ids = items.map(([id]) => id);
1399
+ const places = await this.placesOf(collection, ids);
1384
1400
  await this.withNumbers(db, collection, items.length, async (first, session) => {
1385
1401
  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;
1402
+ const operationsAt = (known: ReadonlyMap<string, number>): AnyBulkWriteOperation<Stored>[] => {
1403
+ const placed = new Map(known);
1404
+ return items.map(([id, value], index) => {
1405
+ const seq = first + index;
1406
+ // A new id takes its place at its first write; a later write of the batch keeps it
1407
+ if (!placed.has(id)) placed.set(id, seq);
1408
+ // The guard of an id with a precondition: the write fails when another writer came first
1409
+ const guard = guards.get(id);
1410
+ guards.delete(id);
1411
+ return { replaceOne: { filter: { ...this.writeFilter(id), ...guard }, replacement: this.encode(id, value, seq, placed.get(id)!), upsert: true } };
1412
+ });
1413
+ };
1414
+ // The race of a new id: another process made it after the query of this upsert, so a new try from the
1415
+ // stopped write is a rewrite, with the same number. These numbers stay pending, so no reader passes them.
1416
+ // 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.
1417
+ let known = places;
1418
+ for (let from = 0, tries = 0; ; tries++) {
1419
+ try {
1420
+ await collection.bulkWrite(operationsAt(known).slice(from), { ordered: true, ...(session ? { session } : {}) });
1421
+ return;
1422
+ } catch (error) {
1423
+ if (session || !isDuplicateKey(error)) throw error;
1424
+ if (expected.length > 0) throw await this.lateConflict(collection, expected);
1425
+ const at = duplicateIndex(error);
1426
+ if (at === undefined || (tries > 0 && at === 0)) throw error;
1427
+ from += at;
1428
+ known = await this.placesOf(collection, ids);
1429
+ }
1401
1430
  }
1402
1431
  });
1403
1432
  });
@@ -1410,9 +1439,17 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1410
1439
  const next = applyPatch(this.decode(doc, id)![1], fields) as T;
1411
1440
  // The encoding is checked before a number of the sequence goes
1412
1441
  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
- );
1442
+ await this.withNumbers(db, collection, 1, async (first, session) => {
1443
+ const write = (): Promise<unknown> =>
1444
+ collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) });
1445
+ try {
1446
+ await write();
1447
+ } catch (error) {
1448
+ // The document went after the read, and another process made it again: one new try is a rewrite
1449
+ if (session || !isDuplicateKey(error)) throw error;
1450
+ await write();
1451
+ }
1452
+ });
1416
1453
  return next;
1417
1454
  });
1418
1455
  }