@lossless.org/client 1.23.0 → 1.23.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.
@@ -4,7 +4,6 @@ import type { SmartdataDb } from './classes.db.js';
4
4
  import { assertDataOptions } from './classes.operationbudget.js';
5
5
  import type { SmartdataOperationDeadline } from './classes.operationdeadline.js';
6
6
  import { SmartdataPersistenceError, isMongoDuplicateKeyError } from './classes.persistence.js';
7
- import { logger } from './logging.js';
8
7
 
9
8
  /**
10
9
  * Write revisions let a read set tell writes of other processes apart from
@@ -12,10 +11,12 @@ import { logger } from './logging.js';
12
11
  * partition in the same transaction, and a read set compares the counters it
13
12
  * read before its reads with the current ones.
14
13
  *
15
- * A bump inserts an entry of its own instead of incrementing a shared
16
- * document, so concurrent transactions of one partition never conflict on its
17
- * counter. A counter's value is its base document plus its entries; a
18
- * background compaction folds entries into the base, which keeps the sum.
14
+ * A counter is its base document, which a writer bumps alone, and up to
15
+ * `counterShards - 1` shard documents that overlapping transactions bump
16
+ * instead, so they do not conflict on the base. A list document beside the
17
+ * base holds how many slots are in use, and the transaction that first bumps
18
+ * a slot raises it, so a snapshot reads the bases and lists in one query and
19
+ * only the listed shards of the counters that have any.
19
20
  */
20
21
  export interface ISmartdataWriteRevisionsOptions {
21
22
  /**
@@ -34,21 +35,21 @@ interface IWriteRevisionDocument {
34
35
  _id: string;
35
36
  n: number;
36
37
  generation?: string;
38
+ /** On a counter's list: the slots in use, the base included. */
39
+ slots?: number;
37
40
  }
38
41
 
39
42
  /** The counter every write revision belongs to; a new generation restarts all counters. */
40
43
  const epochId = '$epoch';
44
+ /** Joins a counter's id and the slot of one of its shards, or ends the id of its list. No counter id contains it. */
45
+ const shardSeparator = '\u0000';
41
46
  /**
42
- * Joins a counter's id and the suffix of one of its entries. No counter id
43
- * contains it, so a counter's base and entries are exactly the ids from the
44
- * counter's own up to, not including, the id followed by `entryRangeEnd`.
47
+ * Slots per counter, the base included: as many transactions that overlap in
48
+ * time can bump one counter without conflicting on it.
45
49
  */
46
- const entrySeparator = '\u0000';
47
- const entryRangeEnd = '\u0001';
48
- /** Entries one process adds to a counter before it folds the counter's entries into its base. */
49
- const compactionEntries = 64;
50
- const maximumCompactedEntries = 1024;
51
- const compactionTimeoutMs = 5_000;
50
+ const counterShards = 16;
51
+ /** Counters whose slots a database instance remembers, beyond those an open transaction holds. */
52
+ const maximumRememberedCounters = 4096;
52
53
  const maximumPartitionLength = 512;
53
54
  const maximumPinnedPartitions = 64;
54
55
  /** Beyond this many documents, a write by selector bumps the shared counter instead. */
@@ -88,7 +89,7 @@ const partitionOf = (valueArg: unknown): string | undefined =>
88
89
  typeof valueArg === 'string'
89
90
  && valueArg.length <= maximumPartitionLength
90
91
  && valueArg.isWellFormed()
91
- && !valueArg.includes(entrySeparator)
92
+ && !valueArg.includes(shardSeparator)
92
93
  ? valueArg
93
94
  : undefined;
94
95
 
@@ -99,6 +100,13 @@ export const partitionRevisionId = (collectionArg: string, partitionArg: string)
99
100
  /** @internal The counter of a collection's writes that no partition can place. */
100
101
  export const sharedRevisionId = (collectionArg: string): string => `${collectionArg}$*`;
101
102
 
103
+ /** @internal The document of a counter's slot: the base for slot 0, a shard otherwise. */
104
+ export const revisionSlotId = (idArg: string, slotArg: number): string =>
105
+ slotArg === 0 ? idArg : `${idArg}${shardSeparator}${slotArg}`;
106
+
107
+ /** @internal The document that lists how many slots of a counter are in use; none means only the base. */
108
+ export const revisionSlotListId = (idArg: string): string => `${idArg}${shardSeparator}`;
109
+
102
110
  /**
103
111
  * @internal The counter a stored value of the partition field belongs to:
104
112
  * its partition's for a string the counters can name, the shared one otherwise.
@@ -197,17 +205,120 @@ export const writeRevisionBumpsOfKey = (keyArg: string, idsArg: ReadonlySet<stri
197
205
  return bumps;
198
206
  };
199
207
 
200
- export const beginSmartdataWriteRevisionCommit = (
201
- dbArg: SmartdataDb,
202
- idsArg: ReadonlySet<string>,
203
- ): (() => void) => {
204
- const commits = revisionCommitsOfDb(dbArg);
205
- const ids = new Set(idsArg);
206
- commits.pending.add(ids);
207
- for (const snapshot of commits.snapshots) {
208
- if (snapshot.keys.some((keyArg) => writeRevisionBumpsOfKey(keyArg, ids) > 0)) snapshot.lost = true;
208
+ interface IRevisionSlot {
209
+ /** Attempts of this instance that bump the slot and have not ended yet. */
210
+ holders: number;
211
+ /** When the last such attempt ended, on the `performance.now()` clock. */
212
+ endedAt: number;
213
+ }
214
+
215
+ interface IRevisionCounterSlots {
216
+ /** The slot this instance's last committed bump took; a search for a free slot starts there. */
217
+ preferred: number;
218
+ slots: IRevisionSlot[];
219
+ }
220
+
221
+ /** Per database instance, in least recently bumped order. */
222
+ const revisionSlotsOfDbs = new WeakMap<SmartdataDb, Map<string, IRevisionCounterSlots>>();
223
+
224
+ /**
225
+ * The slot of a counter an attempt that started at `attemptStartedAtArg`
226
+ * bumps: from the preferred one on, the first that no attempt of this instance
227
+ * holds or ended since then, whose commit the attempt's snapshot might not
228
+ * see. Without one, the first that none holds, and the preferred one last.
229
+ * Transactions of other instances may still take the same slot; a conflict
230
+ * then fails the attempt, and its retry starts after the slot's end, so it
231
+ * moves on to another.
232
+ */
233
+ const chooseRevisionSlot = (counterArg: IRevisionCounterSlots, attemptStartedAtArg: number): number => {
234
+ let unheld: number | undefined;
235
+ for (let offset = 0; offset < counterShards; offset++) {
236
+ const slot = (counterArg.preferred + offset) % counterShards;
237
+ const state = counterArg.slots[slot];
238
+ if (state.holders > 0) continue;
239
+ if (state.endedAt < attemptStartedAtArg) return slot;
240
+ unheld ??= slot;
209
241
  }
210
- return () => { commits.pending.delete(ids); };
242
+ return unheld ?? counterArg.preferred;
243
+ };
244
+
245
+ /**
246
+ * @internal The slots one attempt of a transaction bumps. A counter takes its
247
+ * slot at its first bump in the attempt and holds it until the attempt ends,
248
+ * so no other attempt of this instance commits to it in between.
249
+ */
250
+ export interface ISmartdataWriteRevisionAttempt {
251
+ /** The slot of each counter the attempt bumps. */
252
+ readonly slots: ReadonlyMap<string, number>;
253
+ /** Takes a slot of a counter the attempt has not bumped yet; once the attempt ended, none. */
254
+ bump(idArg: string): void;
255
+ /**
256
+ * Right before the commit: until the attempt ends, a snapshot of this
257
+ * instance that depends on its counters cannot tell its bumps from those of
258
+ * other processes.
259
+ */
260
+ commit(): void;
261
+ /**
262
+ * Ends the attempt: releases its slots and, after a commit, makes them the
263
+ * counters' preferred ones. Later calls do nothing.
264
+ */
265
+ finish(committedArg: boolean): void;
266
+ }
267
+
268
+ export const beginSmartdataWriteRevisionAttempt = (
269
+ dbArg: SmartdataDb,
270
+ attemptStartedAtArg: number,
271
+ ): ISmartdataWriteRevisionAttempt => {
272
+ const taken: [IRevisionCounterSlots, number][] = [];
273
+ const slots = new Map<string, number>();
274
+ let pending: ReadonlySet<string> | undefined;
275
+ let finished = false;
276
+ return {
277
+ slots,
278
+ bump: (idArg) => {
279
+ if (finished || slots.has(idArg)) return;
280
+ let counters = revisionSlotsOfDbs.get(dbArg);
281
+ if (!counters) {
282
+ counters = new Map();
283
+ revisionSlotsOfDbs.set(dbArg, counters);
284
+ }
285
+ const counter = counters.get(idArg) ?? {
286
+ preferred: 0,
287
+ slots: Array.from({ length: counterShards }, () => ({ holders: 0, endedAt: -Infinity })),
288
+ };
289
+ counters.delete(idArg);
290
+ counters.set(idArg, counter);
291
+ const slot = chooseRevisionSlot(counter, attemptStartedAtArg);
292
+ counter.slots[slot].holders++;
293
+ taken.push([counter, slot]);
294
+ slots.set(idArg, slot);
295
+ for (const [id, remembered] of counters) {
296
+ if (counters.size <= maximumRememberedCounters) break;
297
+ if (remembered.slots.every((slotArg) => slotArg.holders === 0)) counters.delete(id);
298
+ }
299
+ },
300
+ commit: () => {
301
+ if (finished || pending) return;
302
+ const commits = revisionCommitsOfDb(dbArg);
303
+ const ids = new Set(slots.keys());
304
+ pending = ids;
305
+ commits.pending.add(ids);
306
+ for (const snapshot of commits.snapshots) {
307
+ if (snapshot.keys.some((keyArg) => writeRevisionBumpsOfKey(keyArg, ids) > 0)) snapshot.lost = true;
308
+ }
309
+ },
310
+ finish: (committedArg) => {
311
+ if (finished) return;
312
+ finished = true;
313
+ if (pending) revisionCommitsOfDb(dbArg).pending.delete(pending);
314
+ const endedAt = performance.now();
315
+ for (const [counter, slot] of taken) {
316
+ counter.slots[slot].holders--;
317
+ counter.slots[slot].endedAt = endedAt;
318
+ if (committedArg) counter.preferred = slot;
319
+ }
320
+ },
321
+ };
211
322
  };
212
323
 
213
324
  /**
@@ -247,9 +358,12 @@ const connectedDb = (dbArg: SmartdataDb): plugins.mongodb.Db => {
247
358
 
248
359
  /**
249
360
  * @internal The current value of each snapshot key, or `undefined` for a key
250
- * whose value cannot be known: a missing epoch means the counters restarted.
251
- * Every counter and collection sum is one read, so a compaction that moves
252
- * entries into a base never shows half done. Bounded by its own deadline.
361
+ * whose value cannot be known: a missing epoch means the counters restarted,
362
+ * and a counter with a value or slot count no writer stores stays unknown. One
363
+ * query reads the epoch, the bases and their lists, a second the listed
364
+ * shards. Every part only grows, and a slot is listed by the transaction that
365
+ * first bumps it, so a write that commits between the two queries shows in
366
+ * this snapshot or in the next, never in neither. Bounded by its own deadline.
253
367
  */
254
368
  const readStoredWriteRevisionSnapshot = async (
255
369
  dbArg: SmartdataDb,
@@ -263,10 +377,14 @@ const readStoredWriteRevisionSnapshot = async (
263
377
  timeoutMode: 'cursorLifetime' as const,
264
378
  signal,
265
379
  });
380
+ const readDocuments = async (idsArg: string[]) => new Map((await collection.find(
381
+ { _id: { $in: idsArg } },
382
+ { projection: { n: 1, generation: 1, slots: 1 }, ...options() },
383
+ ).toArray()).map((documentArg) => [documentArg._id, documentArg]));
266
384
  // `$sum` skips a value that is not a number, so such values are counted apart.
267
- const sumOf = async (fromArg: string, toArg: string): Promise<string | undefined> => {
385
+ const sumOf = async (collectionArg: string): Promise<string | undefined> => {
268
386
  const [sum] = await collection.aggregate<{ n: unknown; invalid: unknown }>([
269
- { $match: { _id: { $gte: fromArg, $lt: toArg } } },
387
+ { $match: { _id: { $gte: `${collectionArg}$`, $lt: `${collectionArg}%` } } },
270
388
  { $group: { _id: null, n: { $sum: '$n' }, invalid: { $sum: { $cond: [{ $isNumber: '$n' }, 0, 1] } } } },
271
389
  ], options()).toArray();
272
390
  if (sum === undefined) return '0';
@@ -274,26 +392,44 @@ const readStoredWriteRevisionSnapshot = async (
274
392
  ? String(sum.n)
275
393
  : undefined;
276
394
  };
277
- const sums = keysArg.flatMap((keyArg): [string, Promise<string | undefined>][] => {
278
- if (keyArg.startsWith('c')) {
279
- const id = keyArg.slice(1);
280
- return [[keyArg, sumOf(id, `${id}${entryRangeEnd}`)]];
281
- }
282
- if (keyArg.startsWith('r')) {
283
- const collectionName = keyArg.slice(1);
284
- return [[keyArg, sumOf(`${collectionName}$`, `${collectionName}%`)]];
285
- }
286
- return [];
287
- });
288
- const [epoch, ...values] = await Promise.all([
289
- collection.findOne({ _id: epochId }, { projection: { n: 1, generation: 1 }, ...options() }),
290
- ...sums.map(([, sumArg]) => sumArg),
395
+ const counterIds = keysArg.flatMap((keyArg) => keyArg.startsWith('c') ? [keyArg.slice(1)] : []);
396
+ const ranges = keysArg.flatMap((keyArg) => keyArg.startsWith('r') ? [keyArg.slice(1)] : []);
397
+ const [bases, ...sums] = await Promise.all([
398
+ readDocuments([epochId, ...counterIds.flatMap((idArg) => [idArg, revisionSlotListId(idArg)])]),
399
+ ...ranges.map(sumOf),
291
400
  ]);
401
+ /** The slots each counter's list holds, or `undefined` for a count no writer stores. */
402
+ const slotCounts = new Map(counterIds.map((idArg): [string, number | undefined] => {
403
+ const list = bases.get(revisionSlotListId(idArg));
404
+ const count: unknown = list === undefined ? 1 : list.slots;
405
+ return [idArg, Number.isSafeInteger(count) && (count as number) >= 1 && (count as number) <= counterShards
406
+ ? count as number
407
+ : undefined];
408
+ }));
409
+ const shardIds = counterIds.flatMap((idArg) => Array.from(
410
+ { length: (slotCounts.get(idArg) ?? 1) - 1 },
411
+ (_unusedArg, indexArg) => revisionSlotId(idArg, indexArg + 1),
412
+ ));
413
+ const shards = shardIds.length === 0 ? new Map<string, IWriteRevisionDocument>() : await readDocuments(shardIds);
292
414
  const snapshot = new Map<string, string | undefined>();
415
+ const epoch = bases.get(epochId);
293
416
  snapshot.set(epochKey, typeof epoch?.generation === 'string' && Number.isSafeInteger(epoch.n)
294
417
  ? `${epoch.generation}:${epoch.n}`
295
418
  : undefined);
296
- for (const [index, [key]] of sums.entries()) snapshot.set(key, values[index]);
419
+ for (const id of counterIds) {
420
+ const count = slotCounts.get(id);
421
+ let sum: number | undefined = count === undefined ? undefined : 0;
422
+ for (let slot = 0; sum !== undefined && slot < count!; slot++) {
423
+ // A slot is missing until its first bump commits; a later one may commit first.
424
+ const document = slot === 0 ? bases.get(id) : shards.get(revisionSlotId(id, slot));
425
+ if (document === undefined) continue;
426
+ sum = Number.isSafeInteger(document.n) && Number.isSafeInteger(sum + document.n)
427
+ ? sum + document.n
428
+ : undefined;
429
+ }
430
+ snapshot.set(counterKey(id), sum === undefined ? undefined : String(sum));
431
+ }
432
+ for (const [index, collectionName] of ranges.entries()) snapshot.set(rangeKey(collectionName), sums[index]);
297
433
  return snapshot;
298
434
  };
299
435
 
@@ -364,129 +500,51 @@ export const bumpSmartdataWriteRevisionEpoch = async (
364
500
 
365
501
  /**
366
502
  * @internal Bumps the counters a transaction's writes touched, inside that
367
- * transaction, right before its commit: one new entry per counter, which no
368
- * concurrent transaction writes.
503
+ * transaction, right before its commit: each in the slot it took. A shard
504
+ * bump first makes sure the counter's list exists and holds the slot; once it
505
+ * does, neither update changes it, so later bumps of listed slots write only
506
+ * their own document. A slot whose value is no number stays as it is, which
507
+ * keeps its counter unknown, and the write still commits.
369
508
  */
370
509
  export const flushSmartdataWriteRevisions = async (
371
510
  mongoDbArg: plugins.mongodb.Db,
372
511
  rawSessionArg: plugins.mongodb.ClientSession,
373
- idsArg: ReadonlySet<string>,
512
+ slotsArg: ReadonlyMap<string, number>,
374
513
  signalArg: AbortSignal | undefined,
375
514
  ): Promise<void> => {
376
- if (idsArg.size === 0) return;
377
- await revisionsCollection(mongoDbArg).insertMany([...idsArg].map((idArg) => ({
378
- _id: `${idArg}${entrySeparator}${plugins.nodeCrypto.randomUUID()}`,
379
- n: 1,
380
- })), {
515
+ if (slotsArg.size === 0) return;
516
+ const operations: plugins.mongodb.AnyBulkWriteOperation<IWriteRevisionDocument>[] = [];
517
+ for (const [id, slot] of [...slotsArg].sort(([aArg], [bArg]) => aArg < bArg ? -1 : aArg > bArg ? 1 : 0)) {
518
+ if (slot > 0) {
519
+ const listId = revisionSlotListId(id);
520
+ operations.push(
521
+ { updateOne: { filter: { _id: listId }, update: { $setOnInsert: { n: 0 } }, upsert: true } },
522
+ {
523
+ updateOne: {
524
+ filter: { _id: listId, $or: [{ slots: { $exists: false } }, { slots: { $lt: slot + 1 } }] },
525
+ update: { $set: { slots: slot + 1 } },
526
+ },
527
+ },
528
+ );
529
+ }
530
+ const slotId = revisionSlotId(id, slot);
531
+ operations.push(
532
+ {
533
+ updateOne: {
534
+ filter: { _id: slotId, $or: [{ n: { $type: 'int' } }, { n: { $type: 'long' } }, { n: { $type: 'double' } }] },
535
+ update: { $inc: { n: 1 } },
536
+ },
537
+ },
538
+ { updateOne: { filter: { _id: slotId }, update: { $setOnInsert: { n: 1 } }, upsert: true } },
539
+ );
540
+ }
541
+ await revisionsCollection(mongoDbArg).bulkWrite(operations, {
381
542
  ordered: true,
382
543
  session: rawSessionArg,
383
544
  ...(signalArg === undefined ? {} : { signal: signalArg }),
384
545
  });
385
546
  };
386
547
 
387
- interface IWriteRevisionCompactions {
388
- /** Committed entries this process added per counter since it last compacted it. */
389
- entries: Map<string, number>;
390
- running: Map<string, Promise<void>>;
391
- controller: AbortController;
392
- }
393
-
394
- const revisionCompactions = new WeakMap<SmartdataDb, IWriteRevisionCompactions>();
395
-
396
- /**
397
- * Folds up to `maximumCompactedEntries` entries of one counter into its base
398
- * in one transaction, which keeps the counter's value. A concurrent compaction
399
- * of the same counter conflicts on the base and on the entries, and the driver
400
- * retries it within the deadline; one that still fails leaves the entries for
401
- * the next.
402
- */
403
- const compactWriteRevisionEntries = async (
404
- dbArg: SmartdataDb,
405
- idArg: string,
406
- signalArg: AbortSignal,
407
- ): Promise<void> => {
408
- const collection = revisionsCollection(connectedDb(dbArg));
409
- const abortable: plugins.mongodb.Abortable = { signal: signalArg };
410
- const session = dbArg.mongoDbClient.startSession();
411
- try {
412
- await session.withTransaction(async () => {
413
- const entries = await collection.find(
414
- { _id: { $gt: `${idArg}${entrySeparator}`, $lt: `${idArg}${entryRangeEnd}` } },
415
- { projection: { n: 1 }, limit: maximumCompactedEntries, session, ...abortable },
416
- ).toArray();
417
- if (entries.length === 0) return;
418
- let sum = 0;
419
- for (const entry of entries) {
420
- if (!Number.isSafeInteger(entry.n)) {
421
- throw new SmartdataPersistenceError('invalid_document',
422
- `Write-revision entry "${entry._id}" holds no safe integer.`);
423
- }
424
- sum += entry.n;
425
- if (!Number.isSafeInteger(sum)) {
426
- throw new SmartdataPersistenceError('invalid_document',
427
- `Write-revision entries of "${idArg}" do not sum to a safe integer.`);
428
- }
429
- }
430
- await collection.deleteMany(
431
- { _id: { $in: entries.map((entryArg) => entryArg._id) } },
432
- { session, ...abortable },
433
- );
434
- await collection.updateOne(
435
- { _id: idArg },
436
- { $inc: { n: sum } },
437
- { upsert: true, session, ...abortable },
438
- );
439
- }, { timeoutMS: compactionTimeoutMs });
440
- } finally {
441
- await session.endSession();
442
- }
443
- };
444
-
445
- /**
446
- * @internal Counts the entries a committed transaction of this process added,
447
- * and starts a compaction of each counter that reached `compactionEntries`.
448
- * Compactions run in the background; `stopSmartdataWriteRevisionCompactions`
449
- * ends them.
450
- */
451
- export const noteSmartdataWriteRevisionEntries = (
452
- dbArg: SmartdataDb,
453
- idsArg: ReadonlySet<string>,
454
- ): void => {
455
- if (idsArg.size === 0 || dbArg.status !== 'connected' || !isSmartdataWriteRevisionsActive(dbArg)) return;
456
- let state = revisionCompactions.get(dbArg);
457
- if (!state) {
458
- state = { entries: new Map(), running: new Map(), controller: new AbortController() };
459
- revisionCompactions.set(dbArg, state);
460
- }
461
- const compactions = state;
462
- for (const id of idsArg) {
463
- const entries = (compactions.entries.get(id) ?? 0) + 1;
464
- if (entries < compactionEntries || compactions.running.has(id)) {
465
- compactions.entries.set(id, entries);
466
- continue;
467
- }
468
- compactions.entries.delete(id);
469
- const signal = compactions.controller.signal;
470
- const compaction = compactWriteRevisionEntries(dbArg, id, signal)
471
- .catch((errorArg) => {
472
- if (signal.aborted) return;
473
- logger.log('warn', `Compacting the write revisions of "${id}" failed; a later compaction retries: ${
474
- errorArg instanceof Error ? errorArg.message : String(errorArg)}`);
475
- })
476
- .finally(() => { compactions.running.delete(id); });
477
- compactions.running.set(id, compaction);
478
- }
479
- };
480
-
481
- /** @internal Aborts the database's compactions and waits until they settled; `close()` calls it. */
482
- export const stopSmartdataWriteRevisionCompactions = async (dbArg: SmartdataDb): Promise<void> => {
483
- const state = revisionCompactions.get(dbArg);
484
- if (!state) return;
485
- revisionCompactions.delete(dbArg);
486
- state.controller.abort();
487
- await Promise.allSettled(state.running.values());
488
- };
489
-
490
548
  /**
491
549
  * @internal The counters of the documents a write by selector is about to
492
550
  * change, read in its transaction before it runs. Snapshot isolation makes