@lossless.org/client 1.22.1 → 1.23.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.bootstraprecord.js +4 -4
- package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
- package/dist_ts/nosqldb/classes.connection.js +3 -2
- package/dist_ts/nosqldb/classes.db.d.ts +31 -0
- package/dist_ts/nosqldb/classes.db.js +45 -2
- package/dist_ts/nosqldb/classes.doc.js +5 -5
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +3 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +8 -7
- package/dist_ts/nosqldb/classes.readtracker.d.ts +6 -4
- package/dist_ts/nosqldb/classes.readtracker.js +42 -10
- package/dist_ts/nosqldb/classes.session.d.ts +7 -6
- package/dist_ts/nosqldb/classes.session.js +91 -23
- package/dist_ts/nosqldb/classes.writelog.d.ts +129 -0
- package/dist_ts/nosqldb/classes.writelog.js +497 -0
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +13 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +47 -25
- package/dist_ts/nosqldb/classes.writerevisions.d.ts +18 -7
- package/dist_ts/nosqldb/classes.writerevisions.js +179 -30
- package/dist_ts/nosqldb/engineidentity.d.ts +6 -0
- package/dist_ts/nosqldb/engineidentity.js +14 -1
- package/package.json +2 -2
- package/readme.md +11 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.bootstraprecord.ts +3 -3
- package/ts/nosqldb/classes.connection.ts +5 -1
- package/ts/nosqldb/classes.db.ts +68 -0
- package/ts/nosqldb/classes.doc.ts +4 -4
- package/ts/nosqldb/classes.exactpersistence.ts +8 -7
- package/ts/nosqldb/classes.readtracker.ts +41 -7
- package/ts/nosqldb/classes.session.ts +107 -26
- package/ts/nosqldb/classes.writelog.ts +701 -0
- package/ts/nosqldb/classes.writeobserver.ts +62 -28
- package/ts/nosqldb/classes.writerevisions.ts +224 -32
- package/ts/nosqldb/engineidentity.ts +16 -0
|
@@ -77,28 +77,37 @@ interface IWriteObserver {
|
|
|
77
77
|
const maximumObservedFields = 64;
|
|
78
78
|
const observersByDb = new WeakMap<SmartdataDb, Set<IWriteObserver>>();
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
/**
|
|
81
|
+
* @internal Validates the top-level field names a report carries: 1 to 64
|
|
82
|
+
* distinct names, no `.` and no leading `$`. `subjectArg` names what requires
|
|
83
|
+
* them in the refusal.
|
|
84
|
+
*/
|
|
85
|
+
export const normalizeObservedFields = (fieldsArg: unknown, subjectArg: string): readonly string[] => {
|
|
86
86
|
if (
|
|
87
|
-
!Array.isArray(
|
|
88
|
-
||
|
|
89
|
-
||
|
|
90
|
-
||
|
|
87
|
+
!Array.isArray(fieldsArg)
|
|
88
|
+
|| fieldsArg.length === 0
|
|
89
|
+
|| fieldsArg.length > maximumObservedFields
|
|
90
|
+
|| fieldsArg.some((fieldArg) =>
|
|
91
91
|
typeof fieldArg !== 'string'
|
|
92
92
|
|| fieldArg.length === 0
|
|
93
93
|
|| fieldArg.includes('.')
|
|
94
94
|
|| fieldArg.startsWith('$'))
|
|
95
|
-
|| new Set(
|
|
95
|
+
|| new Set(fieldsArg).size !== fieldsArg.length
|
|
96
96
|
) {
|
|
97
97
|
throw new SmartdataPersistenceError('invalid_argument',
|
|
98
|
-
|
|
98
|
+
`${subjectArg} requires 1 to ${maximumObservedFields} distinct top-level field names.`);
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze([...fieldsArg] as string[]);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
|
|
104
|
+
const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
|
|
105
|
+
if (typeof listener !== 'function') {
|
|
106
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
107
|
+
'A SmartData write observer requires a listener function.');
|
|
99
108
|
}
|
|
100
109
|
return {
|
|
101
|
-
fields:
|
|
110
|
+
fields: normalizeObservedFields(fields, 'A SmartData write observer'),
|
|
102
111
|
listener: listener as IWriteObserver['listener'],
|
|
103
112
|
};
|
|
104
113
|
};
|
|
@@ -195,6 +204,41 @@ const collectSelectorAnchors = (
|
|
|
195
204
|
}
|
|
196
205
|
};
|
|
197
206
|
|
|
207
|
+
/**
|
|
208
|
+
* @internal The report of one acknowledged write with the values of `fieldsArg`,
|
|
209
|
+
* copied now and frozen.
|
|
210
|
+
*/
|
|
211
|
+
export const reportOfSmartdataWrite = (
|
|
212
|
+
writeArg: ISmartdataWrite,
|
|
213
|
+
fieldsArg: readonly string[],
|
|
214
|
+
): ISmartdataWriteReport => {
|
|
215
|
+
const anchors: Record<string, TSmartdataObservedValue> = {};
|
|
216
|
+
collectSelectorAnchors(writeArg.selector, new Set(fieldsArg), writeArg.identityDocumentIdField, anchors);
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
collection: writeArg.collection,
|
|
219
|
+
kind: writeArg.kind,
|
|
220
|
+
documents: writeArg.documents === null
|
|
221
|
+
? null
|
|
222
|
+
: Object.freeze(writeArg.documents.map((documentArg) =>
|
|
223
|
+
Object.freeze(pickDocumentFields(documentArg, fieldsArg)))),
|
|
224
|
+
anchors: Object.freeze(anchors),
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** @internal The same report with only the values of `fieldsArg`, which the report's fields include. */
|
|
229
|
+
export const narrowSmartdataWriteReport = (
|
|
230
|
+
reportArg: ISmartdataWriteReport,
|
|
231
|
+
fieldsArg: readonly string[],
|
|
232
|
+
): ISmartdataWriteReport => Object.freeze({
|
|
233
|
+
collection: reportArg.collection,
|
|
234
|
+
kind: reportArg.kind,
|
|
235
|
+
documents: reportArg.documents === null
|
|
236
|
+
? null
|
|
237
|
+
: Object.freeze(reportArg.documents.map((documentArg) =>
|
|
238
|
+
Object.freeze(pickDocumentFields(documentArg, fieldsArg)))),
|
|
239
|
+
anchors: Object.freeze(pickDocumentFields(reportArg.anchors, fieldsArg)),
|
|
240
|
+
});
|
|
241
|
+
|
|
198
242
|
const describeError = (errorArg: unknown): string =>
|
|
199
243
|
errorArg instanceof Error ? errorArg.message : String(errorArg);
|
|
200
244
|
|
|
@@ -212,25 +256,15 @@ export const captureSmartdataWrite = (
|
|
|
212
256
|
const registered = observersByDb.get(dbArg);
|
|
213
257
|
if (!registered || registered.size === 0) return undefined;
|
|
214
258
|
const observers = [...registered];
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
collectSelectorAnchors(writeArg.selector, new Set(fields), writeArg.identityDocumentIdField, anchors);
|
|
259
|
+
const report = reportOfSmartdataWrite(
|
|
260
|
+
writeArg,
|
|
261
|
+
[...new Set(observers.flatMap((observerArg) => observerArg.fields))],
|
|
262
|
+
);
|
|
220
263
|
return () => {
|
|
221
264
|
for (const observer of observers) {
|
|
222
265
|
if (!observersByDb.get(dbArg)?.has(observer)) continue;
|
|
223
|
-
const report: ISmartdataWriteReport = Object.freeze({
|
|
224
|
-
collection: writeArg.collection,
|
|
225
|
-
kind: writeArg.kind,
|
|
226
|
-
documents: documents === null
|
|
227
|
-
? null
|
|
228
|
-
: Object.freeze(documents.map((documentArg) =>
|
|
229
|
-
Object.freeze(pickDocumentFields(documentArg, observer.fields)))),
|
|
230
|
-
anchors: Object.freeze(pickDocumentFields(anchors, observer.fields)),
|
|
231
|
-
});
|
|
232
266
|
try {
|
|
233
|
-
observer.listener(report);
|
|
267
|
+
observer.listener(narrowSmartdataWriteReport(report, observer.fields));
|
|
234
268
|
} catch (errorArg) {
|
|
235
269
|
logger.log('error',
|
|
236
270
|
`A SmartData write observer threw on a write to "${writeArg.collection}": ${describeError(errorArg)}`);
|
|
@@ -4,12 +4,18 @@ 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';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Write revisions let a read set tell writes of other processes apart from
|
|
10
11
|
* no writes at all. Every model write bumps a counter per collection and
|
|
11
12
|
* partition in the same transaction, and a read set compares the counters it
|
|
12
13
|
* read before its reads with the current ones.
|
|
14
|
+
*
|
|
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.
|
|
13
19
|
*/
|
|
14
20
|
export interface ISmartdataWriteRevisionsOptions {
|
|
15
21
|
/**
|
|
@@ -32,6 +38,17 @@ interface IWriteRevisionDocument {
|
|
|
32
38
|
|
|
33
39
|
/** The counter every write revision belongs to; a new generation restarts all counters. */
|
|
34
40
|
const epochId = '$epoch';
|
|
41
|
+
/**
|
|
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`.
|
|
45
|
+
*/
|
|
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;
|
|
35
52
|
const maximumPartitionLength = 512;
|
|
36
53
|
const maximumPinnedPartitions = 64;
|
|
37
54
|
/** Beyond this many documents, a write by selector bumps the shared counter instead. */
|
|
@@ -68,7 +85,10 @@ export const isSmartdataWriteRevisionsActive = (dbArg: SmartdataDb): boolean =>
|
|
|
68
85
|
|
|
69
86
|
/** A string the counters can name, or `undefined`. */
|
|
70
87
|
const partitionOf = (valueArg: unknown): string | undefined =>
|
|
71
|
-
typeof valueArg === 'string'
|
|
88
|
+
typeof valueArg === 'string'
|
|
89
|
+
&& valueArg.length <= maximumPartitionLength
|
|
90
|
+
&& valueArg.isWellFormed()
|
|
91
|
+
&& !valueArg.includes(entrySeparator)
|
|
72
92
|
? valueArg
|
|
73
93
|
: undefined;
|
|
74
94
|
|
|
@@ -145,6 +165,51 @@ const epochKey = 'e';
|
|
|
145
165
|
const counterKey = (idArg: string): string => `c${idArg}`;
|
|
146
166
|
const rangeKey = (collectionArg: string): string => `r${collectionArg}`;
|
|
147
167
|
|
|
168
|
+
interface ILocalRevisionSnapshot {
|
|
169
|
+
keys: readonly string[];
|
|
170
|
+
lost: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface ILocalRevisionCommits {
|
|
174
|
+
pending: Set<ReadonlySet<string>>;
|
|
175
|
+
snapshots: Set<ILocalRevisionSnapshot>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const localRevisionCommits = new WeakMap<SmartdataDb, ILocalRevisionCommits>();
|
|
179
|
+
|
|
180
|
+
const revisionCommitsOfDb = (dbArg: SmartdataDb): ILocalRevisionCommits => {
|
|
181
|
+
let commits = localRevisionCommits.get(dbArg);
|
|
182
|
+
if (!commits) {
|
|
183
|
+
commits = { pending: new Set(), snapshots: new Set() };
|
|
184
|
+
localRevisionCommits.set(dbArg, commits);
|
|
185
|
+
}
|
|
186
|
+
return commits;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const writeRevisionBumpsOfKey = (keyArg: string, idsArg: ReadonlySet<string>): number => {
|
|
190
|
+
if (keyArg.startsWith('c')) return idsArg.has(keyArg.slice(1)) ? 1 : 0;
|
|
191
|
+
if (!keyArg.startsWith('r')) return 0;
|
|
192
|
+
const collection = keyArg.slice(1);
|
|
193
|
+
let bumps = 0;
|
|
194
|
+
for (const id of idsArg) {
|
|
195
|
+
if (id >= `${collection}$` && id < `${collection}%`) bumps++;
|
|
196
|
+
}
|
|
197
|
+
return bumps;
|
|
198
|
+
};
|
|
199
|
+
|
|
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;
|
|
209
|
+
}
|
|
210
|
+
return () => { commits.pending.delete(ids); };
|
|
211
|
+
};
|
|
212
|
+
|
|
148
213
|
/**
|
|
149
214
|
* @internal The snapshot keys one read depends on. A read the field pins
|
|
150
215
|
* depends on its partitions and the shared counter, which holds every
|
|
@@ -183,9 +248,10 @@ const connectedDb = (dbArg: SmartdataDb): plugins.mongodb.Db => {
|
|
|
183
248
|
/**
|
|
184
249
|
* @internal The current value of each snapshot key, or `undefined` for a key
|
|
185
250
|
* whose value cannot be known: a missing epoch means the counters restarted.
|
|
186
|
-
*
|
|
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.
|
|
187
253
|
*/
|
|
188
|
-
|
|
254
|
+
const readStoredWriteRevisionSnapshot = async (
|
|
189
255
|
dbArg: SmartdataDb,
|
|
190
256
|
keysArg: readonly string[],
|
|
191
257
|
): Promise<Map<string, string | undefined>> => {
|
|
@@ -197,39 +263,61 @@ export const readWriteRevisionSnapshot = async (
|
|
|
197
263
|
timeoutMode: 'cursorLifetime' as const,
|
|
198
264
|
signal,
|
|
199
265
|
});
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{ _id: { $
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
// `$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> => {
|
|
268
|
+
const [sum] = await collection.aggregate<{ n: unknown; invalid: unknown }>([
|
|
269
|
+
{ $match: { _id: { $gte: fromArg, $lt: toArg } } },
|
|
270
|
+
{ $group: { _id: null, n: { $sum: '$n' }, invalid: { $sum: { $cond: [{ $isNumber: '$n' }, 0, 1] } } } },
|
|
271
|
+
], options()).toArray();
|
|
272
|
+
if (sum === undefined) return '0';
|
|
273
|
+
return sum.invalid === 0 && typeof sum.n === 'number' && Number.isSafeInteger(sum.n)
|
|
274
|
+
? String(sum.n)
|
|
275
|
+
: undefined;
|
|
276
|
+
};
|
|
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),
|
|
211
291
|
]);
|
|
212
|
-
const byId = new Map(counters.map((documentArg) => [documentArg._id, documentArg]));
|
|
213
292
|
const snapshot = new Map<string, string | undefined>();
|
|
214
|
-
const epoch = byId.get(epochId);
|
|
215
293
|
snapshot.set(epochKey, typeof epoch?.generation === 'string' && Number.isSafeInteger(epoch.n)
|
|
216
294
|
? `${epoch.generation}:${epoch.n}`
|
|
217
295
|
: undefined);
|
|
218
|
-
for (const
|
|
219
|
-
const counter = byId.get(id);
|
|
220
|
-
snapshot.set(counterKey(id), counter === undefined
|
|
221
|
-
? '0'
|
|
222
|
-
: Number.isSafeInteger(counter.n) ? String(counter.n) : undefined);
|
|
223
|
-
}
|
|
224
|
-
for (const [index, collectionArg] of ranges.entries()) {
|
|
225
|
-
const sum = sums[index].length === 0 ? 0 : sums[index][0].n;
|
|
226
|
-
snapshot.set(rangeKey(collectionArg), typeof sum === 'number' && Number.isSafeInteger(sum)
|
|
227
|
-
? String(sum)
|
|
228
|
-
: undefined);
|
|
229
|
-
}
|
|
296
|
+
for (const [index, [key]] of sums.entries()) snapshot.set(key, values[index]);
|
|
230
297
|
return snapshot;
|
|
231
298
|
};
|
|
232
299
|
|
|
300
|
+
export const readWriteRevisionSnapshot = async (
|
|
301
|
+
dbArg: SmartdataDb,
|
|
302
|
+
keysArg: readonly string[],
|
|
303
|
+
): Promise<Map<string, string | undefined>> => {
|
|
304
|
+
const commits = revisionCommitsOfDb(dbArg);
|
|
305
|
+
const snapshot: ILocalRevisionSnapshot = {
|
|
306
|
+
keys: keysArg,
|
|
307
|
+
lost: [...commits.pending].some((idsArg) =>
|
|
308
|
+
keysArg.some((keyArg) => writeRevisionBumpsOfKey(keyArg, idsArg) > 0)),
|
|
309
|
+
};
|
|
310
|
+
commits.snapshots.add(snapshot);
|
|
311
|
+
try {
|
|
312
|
+
const stored = await readStoredWriteRevisionSnapshot(dbArg, keysArg);
|
|
313
|
+
return snapshot.lost
|
|
314
|
+
? new Map(keysArg.map((keyArg) => [keyArg, undefined]))
|
|
315
|
+
: stored;
|
|
316
|
+
} finally {
|
|
317
|
+
commits.snapshots.delete(snapshot);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
233
321
|
/**
|
|
234
322
|
* @internal Creates the epoch of a database that has none, before `init()`
|
|
235
323
|
* publishes the connection. Another process creating it at the same time is
|
|
@@ -276,8 +364,8 @@ export const bumpSmartdataWriteRevisionEpoch = async (
|
|
|
276
364
|
|
|
277
365
|
/**
|
|
278
366
|
* @internal Bumps the counters a transaction's writes touched, inside that
|
|
279
|
-
* transaction, right before its commit
|
|
280
|
-
*
|
|
367
|
+
* transaction, right before its commit: one new entry per counter, which no
|
|
368
|
+
* concurrent transaction writes.
|
|
281
369
|
*/
|
|
282
370
|
export const flushSmartdataWriteRevisions = async (
|
|
283
371
|
mongoDbArg: plugins.mongodb.Db,
|
|
@@ -286,8 +374,9 @@ export const flushSmartdataWriteRevisions = async (
|
|
|
286
374
|
signalArg: AbortSignal | undefined,
|
|
287
375
|
): Promise<void> => {
|
|
288
376
|
if (idsArg.size === 0) return;
|
|
289
|
-
await revisionsCollection(mongoDbArg).
|
|
290
|
-
|
|
377
|
+
await revisionsCollection(mongoDbArg).insertMany([...idsArg].map((idArg) => ({
|
|
378
|
+
_id: `${idArg}${entrySeparator}${plugins.nodeCrypto.randomUUID()}`,
|
|
379
|
+
n: 1,
|
|
291
380
|
})), {
|
|
292
381
|
ordered: true,
|
|
293
382
|
session: rawSessionArg,
|
|
@@ -295,6 +384,109 @@ export const flushSmartdataWriteRevisions = async (
|
|
|
295
384
|
});
|
|
296
385
|
};
|
|
297
386
|
|
|
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
|
+
|
|
298
490
|
/**
|
|
299
491
|
* @internal The counters of the documents a write by selector is about to
|
|
300
492
|
* change, read in its transaction before it runs. Snapshot isolation makes
|
|
@@ -32,6 +32,12 @@ export const unidentifiedSmartdataEngine: ISmartdataEngineIdentity = Object.free
|
|
|
32
32
|
*/
|
|
33
33
|
const compositeGroupingEngineRelease = '10.5.0';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The NoSQLDB release that publishes its durable commit journal through the
|
|
37
|
+
* `getCommittedWrites` command: @lossless.org/nosqldb 10.9.0.
|
|
38
|
+
*/
|
|
39
|
+
const commitJournalEngineRelease = '10.9.0';
|
|
40
|
+
|
|
35
41
|
const parseEngineVersion = (versionArg: string): readonly number[] | undefined => {
|
|
36
42
|
const parts = versionArg.split('.');
|
|
37
43
|
if (parts.length !== 3) {
|
|
@@ -91,6 +97,16 @@ export const smartdataCompositeGrouping = (
|
|
|
91
97
|
}
|
|
92
98
|
};
|
|
93
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether the identified engine publishes a durable, commit-ordered journal of
|
|
102
|
+
* the transactions it committed. Only NoSQLDB does, from 10.9.0 on; MongoDB's
|
|
103
|
+
* change streams are a different contract and are not used for it.
|
|
104
|
+
*/
|
|
105
|
+
export const smartdataCommitJournal = (identityArg: ISmartdataEngineIdentity): boolean =>
|
|
106
|
+
identityArg.product === 'NoSQLDB'
|
|
107
|
+
&& identityArg.version !== null
|
|
108
|
+
&& engineVersionReaches(identityArg.version, commitJournalEngineRelease);
|
|
109
|
+
|
|
94
110
|
export const composeSmartdataCapabilities = (
|
|
95
111
|
backendArg: TSmartdataEngineBackend,
|
|
96
112
|
compositeGroupingArg: TCompositeGrouping,
|