@lossless.org/client 1.21.1 → 1.22.1
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/.smartconfig.json +1 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.atomicdelete.js +8 -4
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +19 -4
- package/dist_ts/nosqldb/classes.atomicupdate.js +16 -5
- package/dist_ts/nosqldb/classes.bootstraprecord.js +31 -5
- package/dist_ts/nosqldb/classes.collection.js +17 -14
- 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 +27 -2
- package/dist_ts/nosqldb/classes.db.js +42 -3
- package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
- package/dist_ts/nosqldb/classes.doc.js +15 -9
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
- package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
- package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
- package/dist_ts/nosqldb/classes.readtracker.d.ts +26 -0
- package/dist_ts/nosqldb/classes.readtracker.js +124 -1
- package/dist_ts/nosqldb/classes.session.d.ts +34 -5
- package/dist_ts/nosqldb/classes.session.js +134 -8
- package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
- package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
- package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
- package/package.json +8 -5
- package/readme.md +10 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +11 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
- package/ts/nosqldb/classes.atomicupdate.ts +25 -3
- package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
- package/ts/nosqldb/classes.collection.ts +20 -12
- package/ts/nosqldb/classes.connection.ts +5 -1
- package/ts/nosqldb/classes.db.ts +65 -2
- package/ts/nosqldb/classes.doc.ts +16 -8
- package/ts/nosqldb/classes.exactpersistence.ts +83 -21
- package/ts/nosqldb/classes.operationdeadline.ts +8 -0
- package/ts/nosqldb/classes.readtracker.ts +139 -0
- package/ts/nosqldb/classes.session.ts +191 -8
- package/ts/nosqldb/classes.storedinspection.ts +6 -3
- package/ts/nosqldb/classes.writeobserver.ts +5 -0
- package/ts/nosqldb/classes.writerevisions.ts +328 -0
|
@@ -5,6 +5,13 @@ import type { SmartdataDb } from './classes.db.js';
|
|
|
5
5
|
import { readBoundedInspectionCursor } from './classes.namespaceinspection.js';
|
|
6
6
|
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
7
7
|
import type { ISmartdataWrite } from './classes.writeobserver.js';
|
|
8
|
+
import {
|
|
9
|
+
isSmartdataWriteRevisionsActive,
|
|
10
|
+
readWriteRevisionSnapshot,
|
|
11
|
+
revisionIdOfValue,
|
|
12
|
+
sharedRevisionId,
|
|
13
|
+
writeRevisionKeysOfRead,
|
|
14
|
+
} from './classes.writerevisions.js';
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* The reads one `db.trackReads()` call made. `changed` turns true once a write
|
|
@@ -22,6 +29,42 @@ export class SmartdataReadSet {
|
|
|
22
29
|
return !state || state.changed;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether no write of any process could have changed what the reads
|
|
34
|
+
* returned: `changed` is false, and the write revisions the reads depend on
|
|
35
|
+
* are what they were before the reads ran. Requires the database's
|
|
36
|
+
* `writeRevisions` option and an engine with transactions; without them,
|
|
37
|
+
* and whenever the revisions cannot be read in time, it is false.
|
|
38
|
+
*
|
|
39
|
+
* The revisions count writes per collection and partition, not per read,
|
|
40
|
+
* so a write of this process that left `changed` false also makes it false
|
|
41
|
+
* when it wrote to a partition or collection the reads depend on.
|
|
42
|
+
*/
|
|
43
|
+
public async isCurrent(): Promise<boolean> {
|
|
44
|
+
const state = readSetStates.get(this);
|
|
45
|
+
const revisions = state?.revisions;
|
|
46
|
+
if (!state || state.changed || !revisions || revisions.lost) return false;
|
|
47
|
+
if (!isSmartdataWriteRevisionsActive(state.db)) return false;
|
|
48
|
+
try {
|
|
49
|
+
// Reads still starting add snapshots; wait until the set has them all.
|
|
50
|
+
let count: number;
|
|
51
|
+
let values: (string | undefined)[];
|
|
52
|
+
do {
|
|
53
|
+
count = revisions.snapshots.size;
|
|
54
|
+
values = await Promise.all(revisions.snapshots.values());
|
|
55
|
+
if (state.changed || revisions.lost) return false;
|
|
56
|
+
} while (revisions.snapshots.size !== count);
|
|
57
|
+
if (count === 0) return true;
|
|
58
|
+
if (values.some((valueArg) => valueArg === undefined)) return false;
|
|
59
|
+
const keys = [...revisions.snapshots.keys()];
|
|
60
|
+
const current = await readWriteRevisionSnapshot(state.db, keys);
|
|
61
|
+
if (state.changed) return false;
|
|
62
|
+
return keys.every((keyArg, indexArg) => current.get(keyArg) === values[indexArg]);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
25
68
|
/** Stops following writes and frees the recorded reads; `changed` is true from then on. */
|
|
26
69
|
public close(): void {
|
|
27
70
|
const state = readSetStates.get(this);
|
|
@@ -67,6 +110,11 @@ export interface ISmartdataReadRecorder {
|
|
|
67
110
|
record(documentArg: unknown): void;
|
|
68
111
|
/** The read returned everything it will return. */
|
|
69
112
|
settle(): void;
|
|
113
|
+
/**
|
|
114
|
+
* Settles once the write revisions the read depends on were read; the read
|
|
115
|
+
* is issued after it. `undefined` when there is nothing to wait for.
|
|
116
|
+
*/
|
|
117
|
+
ready?: Promise<void>;
|
|
70
118
|
}
|
|
71
119
|
|
|
72
120
|
// Values an image or a stored path can hold beside real values.
|
|
@@ -110,6 +158,15 @@ interface IReadSetState {
|
|
|
110
158
|
/** The callback returned, so its read collections have been checked. */
|
|
111
159
|
finished: boolean;
|
|
112
160
|
reads: Map<string, IReadObservation[]>;
|
|
161
|
+
/** The write revisions the reads depend on; only while write revisions are active. */
|
|
162
|
+
revisions?: IReadSetRevisions;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface IReadSetRevisions {
|
|
166
|
+
/** Per snapshot key, its value before the first read that depends on it; `undefined` when unknown. */
|
|
167
|
+
snapshots: Map<string, Promise<string | undefined>>;
|
|
168
|
+
/** A revision could not be read, so the set can never be current. */
|
|
169
|
+
lost: boolean;
|
|
113
170
|
}
|
|
114
171
|
|
|
115
172
|
/** What one write did to one document, as far as it is known. */
|
|
@@ -830,6 +887,7 @@ export const trackSmartdataReads = async <TResult>(
|
|
|
830
887
|
}
|
|
831
888
|
const reads = new SmartdataReadSet();
|
|
832
889
|
const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
|
|
890
|
+
if (isSmartdataWriteRevisionsActive(dbArg)) state.revisions = { snapshots: new Map(), lost: false };
|
|
833
891
|
readSetStates.set(reads, state);
|
|
834
892
|
let states = readSetsByDb.get(dbArg);
|
|
835
893
|
if (!states) {
|
|
@@ -883,6 +941,45 @@ const limitFollowsQuery = (specArg: ISmartdataReadSpec): boolean => {
|
|
|
883
941
|
isPlainRecord(sort) && Object.hasOwn(sort, fieldArg));
|
|
884
942
|
};
|
|
885
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Reads the write revisions a read depends on that its sets have no snapshot
|
|
946
|
+
* of yet, once for all of them. The earliest snapshot of a key stays, so a set
|
|
947
|
+
* compares against the state before its first read of it.
|
|
948
|
+
*/
|
|
949
|
+
const snapshotWriteRevisions = (
|
|
950
|
+
dbArg: SmartdataDb,
|
|
951
|
+
specArg: ISmartdataReadSpec,
|
|
952
|
+
statesArg: readonly IReadSetState[],
|
|
953
|
+
): Promise<void> | undefined => {
|
|
954
|
+
const states = statesArg.filter((stateArg) => stateArg.revisions && !stateArg.revisions.lost);
|
|
955
|
+
if (states.length === 0) return undefined;
|
|
956
|
+
if (!isSmartdataWriteRevisionsActive(dbArg)) {
|
|
957
|
+
for (const state of states) state.revisions!.lost = true;
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
const keys = writeRevisionKeysOfRead(dbArg, specArg.collection, specArg.kind, specArg.filter);
|
|
961
|
+
const missing = keys.filter((keyArg) =>
|
|
962
|
+
states.some((stateArg) => !stateArg.revisions!.snapshots.has(keyArg)));
|
|
963
|
+
if (missing.length === 0) return undefined;
|
|
964
|
+
const snapshot = (async () => readWriteRevisionSnapshot(dbArg, missing))()
|
|
965
|
+
.catch(() => undefined);
|
|
966
|
+
const pending: Promise<unknown>[] = [];
|
|
967
|
+
for (const state of states) {
|
|
968
|
+
const revisions = state.revisions!;
|
|
969
|
+
for (const key of missing) {
|
|
970
|
+
if (revisions.snapshots.has(key)) continue;
|
|
971
|
+
const value = snapshot.then((snapshotArg) => {
|
|
972
|
+
const known = snapshotArg?.get(key);
|
|
973
|
+
if (known === undefined) revisions.lost = true;
|
|
974
|
+
return known;
|
|
975
|
+
});
|
|
976
|
+
revisions.snapshots.set(key, value);
|
|
977
|
+
pending.push(value);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return Promise.all(pending).then(() => undefined);
|
|
981
|
+
};
|
|
982
|
+
|
|
886
983
|
/**
|
|
887
984
|
* @internal Registers one read with every open read set of the current async
|
|
888
985
|
* context, before the read is issued. A read inside a transaction sees writes
|
|
@@ -948,6 +1045,7 @@ export const observeSmartdataRead = (
|
|
|
948
1045
|
reads.push(observation);
|
|
949
1046
|
}
|
|
950
1047
|
return {
|
|
1048
|
+
ready: snapshotWriteRevisions(dbArg, specArg, followed),
|
|
951
1049
|
record: (documentArg) => {
|
|
952
1050
|
if (observation.settled) return;
|
|
953
1051
|
if (!isPlainRecord(documentArg)) {
|
|
@@ -1029,3 +1127,44 @@ export const captureSmartdataCollectionChange = (
|
|
|
1029
1127
|
if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
|
|
1030
1128
|
return () => applyChanges(dbArg, collectionArg, [{ kind: 'unknown', touched: 'all', startedAt: 0 }]);
|
|
1031
1129
|
};
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* @internal The write-revision counters one acknowledged write bumps: the
|
|
1133
|
+
* partition of each document state it names before and after, and the shared
|
|
1134
|
+
* counter for each state it cannot place. A state before the write that the
|
|
1135
|
+
* report does not carry was read ahead of the write when
|
|
1136
|
+
* `previousPartitionsRead` is set; a field the write left alone keeps the
|
|
1137
|
+
* value it has afterwards.
|
|
1138
|
+
*/
|
|
1139
|
+
export const writeRevisionIdsOfWrite = (
|
|
1140
|
+
writeArg: ISmartdataWrite,
|
|
1141
|
+
fieldArg: string,
|
|
1142
|
+
): Set<string> => {
|
|
1143
|
+
const collection = writeArg.collection;
|
|
1144
|
+
const ids = new Set<string>();
|
|
1145
|
+
const add = (valueArg: unknown) => {
|
|
1146
|
+
ids.add(valueArg === UNKNOWN
|
|
1147
|
+
? sharedRevisionId(collection)
|
|
1148
|
+
: revisionIdOfValue(collection, valueArg === MISSING ? undefined : valueArg));
|
|
1149
|
+
};
|
|
1150
|
+
for (const change of describeWrite(writeArg, 0)) {
|
|
1151
|
+
if (change.kind === 'unknown') {
|
|
1152
|
+
ids.add(sharedRevisionId(collection));
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const fieldTouched = change.touched === 'all'
|
|
1156
|
+
|| [...change.touched].some((pathArg) => pathsOverlap(pathArg, fieldArg));
|
|
1157
|
+
const after = change.kind === 'delete' ? undefined : lookupPath(change.after, fieldArg);
|
|
1158
|
+
if (change.kind !== 'delete' && (after !== UNKNOWN || fieldTouched || change.kind === 'insert')) {
|
|
1159
|
+
add(after);
|
|
1160
|
+
}
|
|
1161
|
+
if (change.kind === 'insert') continue;
|
|
1162
|
+
const before = lookupPath(change.before, fieldArg);
|
|
1163
|
+
if (before !== UNKNOWN) {
|
|
1164
|
+
add(before);
|
|
1165
|
+
} else if (!writeArg.previousPartitionsRead && (fieldTouched || after === UNKNOWN)) {
|
|
1166
|
+
add(UNKNOWN);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return ids;
|
|
1170
|
+
};
|
|
@@ -30,9 +30,17 @@ import {
|
|
|
30
30
|
hasSmartdataReadSets,
|
|
31
31
|
observeSmartdataRead,
|
|
32
32
|
runSmartdataWrite,
|
|
33
|
+
writeRevisionIdsOfWrite,
|
|
33
34
|
type ISmartdataReadRecorder,
|
|
34
35
|
type ISmartdataReadSpec,
|
|
35
36
|
} from './classes.readtracker.js';
|
|
37
|
+
import {
|
|
38
|
+
flushSmartdataWriteRevisions,
|
|
39
|
+
isSmartdataWriteRevisionsActive,
|
|
40
|
+
readPreviousRevisionIds,
|
|
41
|
+
sharedRevisionId,
|
|
42
|
+
writeRevisionsPartitionField,
|
|
43
|
+
} from './classes.writerevisions.js';
|
|
36
44
|
|
|
37
45
|
interface ISmartdataSessionBinding {
|
|
38
46
|
session: SmartdataSession;
|
|
@@ -60,6 +68,8 @@ interface ISmartdataSessionBinding {
|
|
|
60
68
|
* when the commit outcome is unknown.
|
|
61
69
|
*/
|
|
62
70
|
transactionWrites: ISmartdataTransactionWrite[];
|
|
71
|
+
/** The write-revision counters the current attempt's writes bump right before its commit. */
|
|
72
|
+
revisionBumps: Set<string>;
|
|
63
73
|
operationSettled?: Promise<void>;
|
|
64
74
|
resolveOperationSettled?: () => void;
|
|
65
75
|
invalidated: boolean;
|
|
@@ -136,6 +146,8 @@ const createTransactionSessionEndedError = (
|
|
|
136
146
|
);
|
|
137
147
|
|
|
138
148
|
const bindings = new WeakMap<SmartdataSession, ISmartdataSessionBinding>();
|
|
149
|
+
/** The binding of each owned session's driver session, for writes that only hold the latter. */
|
|
150
|
+
const bindingsByRawSession = new WeakMap<plugins.mongodb.ClientSession, ISmartdataSessionBinding>();
|
|
139
151
|
const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
|
|
140
152
|
const smartdataSessionInvalidationAggregateBrand = Symbol(
|
|
141
153
|
'smartdataSessionInvalidationAggregate',
|
|
@@ -156,6 +168,11 @@ interface ISmartdataSessionAcquireOptions {
|
|
|
156
168
|
interface IOrdinarySessionOperationOptions {
|
|
157
169
|
ordinaryWrite?: boolean;
|
|
158
170
|
prepared?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Prepares the collection. A write of a database with write revisions that
|
|
173
|
+
* runs in a transaction of its own prepares first, outside it.
|
|
174
|
+
*/
|
|
175
|
+
prepare?: () => Promise<void>;
|
|
159
176
|
/** Names the collection whose preparation a refusal must ask for. */
|
|
160
177
|
collectionName?: string;
|
|
161
178
|
/** The caller's validated client deadline for the call's database work. */
|
|
@@ -186,6 +203,16 @@ const transactionContext = new plugins.nodeAsyncHooks.AsyncLocalStorage<
|
|
|
186
203
|
ITransactionContext
|
|
187
204
|
>();
|
|
188
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The session of the transaction a write of a database with write revisions
|
|
208
|
+
* runs in when its caller passed none, so the write's reports and reads
|
|
209
|
+
* belong to that transaction.
|
|
210
|
+
*/
|
|
211
|
+
const revisionTransactionScope = new plugins.nodeAsyncHooks.AsyncLocalStorage<{
|
|
212
|
+
owner: SmartdataDb;
|
|
213
|
+
session: SmartdataSession;
|
|
214
|
+
}>();
|
|
215
|
+
|
|
189
216
|
const resolveBinding = (
|
|
190
217
|
sessionArg: SmartdataSession,
|
|
191
218
|
ownerArg?: SmartdataDb,
|
|
@@ -342,6 +369,7 @@ export class SmartdataSession {
|
|
|
342
369
|
binding.transactionFailure = undefined;
|
|
343
370
|
binding.transactionCommitReached = false;
|
|
344
371
|
binding.transactionWrites = [];
|
|
372
|
+
binding.revisionBumps = new Set();
|
|
345
373
|
callbackError = undefined;
|
|
346
374
|
attemptInterruption = undefined;
|
|
347
375
|
try {
|
|
@@ -393,6 +421,16 @@ export class SmartdataSession {
|
|
|
393
421
|
}
|
|
394
422
|
// An abort that arrived while the callback ran rolls the attempt back.
|
|
395
423
|
signal?.throwIfAborted();
|
|
424
|
+
// The writes' revisions commit with them. A failure fails the attempt
|
|
425
|
+
// before its commit, and a transient one is retried with it.
|
|
426
|
+
if (binding.revisionBumps.size > 0) {
|
|
427
|
+
await flushSmartdataWriteRevisions(
|
|
428
|
+
binding.client.db(binding.owner.smartdataOptions.mongoDbName),
|
|
429
|
+
binding.rawSession,
|
|
430
|
+
binding.revisionBumps,
|
|
431
|
+
signal,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
396
434
|
// The driver commits next; a deadline that expires or a session that
|
|
397
435
|
// ends from here on leaves the commit outcome unknown.
|
|
398
436
|
binding.transactionCommitReached = true;
|
|
@@ -490,6 +528,7 @@ export class SmartdataSession {
|
|
|
490
528
|
binding.transactionCommitReached = false;
|
|
491
529
|
binding.transactionEnded = undefined;
|
|
492
530
|
binding.transactionWrites = [];
|
|
531
|
+
binding.revisionBumps = new Set();
|
|
493
532
|
}
|
|
494
533
|
if (transactionEnded) {
|
|
495
534
|
const outcome = commitOutcomeOfTransactionEnd(transactionEnded);
|
|
@@ -538,9 +577,11 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
|
|
|
538
577
|
transactionUsesOrdinaryWrite: false,
|
|
539
578
|
transactionCommitReached: false,
|
|
540
579
|
transactionWrites: [],
|
|
580
|
+
revisionBumps: new Set(),
|
|
541
581
|
invalidated: false,
|
|
542
582
|
};
|
|
543
583
|
bindings.set(session, binding);
|
|
584
|
+
bindingsByRawSession.set(binding.rawSession, binding);
|
|
544
585
|
let ownerBindings = bindingsByOwner.get(ownerArg);
|
|
545
586
|
if (!ownerBindings) {
|
|
546
587
|
ownerBindings = new Set();
|
|
@@ -780,12 +821,90 @@ export const settleSmartdataOperationError = (
|
|
|
780
821
|
return error;
|
|
781
822
|
};
|
|
782
823
|
|
|
824
|
+
/**
|
|
825
|
+
* The session of the transaction a write of a database with write revisions
|
|
826
|
+
* runs in on its caller's behalf, while that transaction runs.
|
|
827
|
+
*/
|
|
828
|
+
const revisionTransactionSessionOf = (ownerArg: SmartdataDb): SmartdataSession | undefined => {
|
|
829
|
+
const scope = revisionTransactionScope.getStore();
|
|
830
|
+
return scope?.owner === ownerArg && bindings.get(scope.session)?.transactionActive
|
|
831
|
+
? scope.session
|
|
832
|
+
: undefined;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Whether a write must start a transaction of its own to carry its write
|
|
837
|
+
* revisions: the database keeps them and the write runs outside an owned
|
|
838
|
+
* transaction. A raw driver session cannot carry them and is refused.
|
|
839
|
+
*/
|
|
840
|
+
export const needsSmartdataRevisionTransaction = (
|
|
841
|
+
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
842
|
+
ownerArg: SmartdataDb,
|
|
843
|
+
): boolean => {
|
|
844
|
+
if (!isSmartdataWriteRevisionsActive(ownerArg)) return false;
|
|
845
|
+
if (sessionArg !== undefined && !isSmartdataSession(sessionArg)) {
|
|
846
|
+
throw new SmartdataPersistenceError(
|
|
847
|
+
'unsupported_operation',
|
|
848
|
+
'Writes of a database with write revisions run on sessions of db.createSession(), not raw driver sessions.',
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
return sessionArg === undefined || !resolveBinding(sessionArg, ownerArg).transactionActive;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const transactionOptionsOf = (
|
|
855
|
+
timeoutMSArg: number | undefined,
|
|
856
|
+
signalArg: AbortSignal | undefined,
|
|
857
|
+
): ISmartdataTransactionOptions => ({
|
|
858
|
+
...(timeoutMSArg === undefined ? {} : { timeoutMS: timeoutMSArg }),
|
|
859
|
+
...(signalArg === undefined ? {} : { signal: signalArg }),
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Runs one write of a database with write revisions in a transaction, so its
|
|
864
|
+
* revisions commit with it: on the caller's owned session, or on a session of
|
|
865
|
+
* its own that ends with the call. The call's deadline and signal bound the
|
|
866
|
+
* whole transaction; the collection is prepared before it starts, since its
|
|
867
|
+
* DDL cannot run inside.
|
|
868
|
+
*/
|
|
869
|
+
export const runSmartdataRevisionTransaction = async <TResult>(
|
|
870
|
+
sessionArg: SmartdataSession | undefined,
|
|
871
|
+
ownerArg: SmartdataDb,
|
|
872
|
+
optionsArg: Pick<
|
|
873
|
+
IOrdinarySessionOperationOptions,
|
|
874
|
+
'prepared' | 'prepare' | 'collectionName' | 'timeoutMS' | 'defaultTimeoutMS' | 'signal'
|
|
875
|
+
>,
|
|
876
|
+
writeArg: (sessionArg: SmartdataSession) => Promise<TResult>,
|
|
877
|
+
): Promise<TResult> => {
|
|
878
|
+
const timeoutMS = optionsArg.timeoutMS ?? optionsArg.defaultTimeoutMS;
|
|
879
|
+
if (sessionArg) {
|
|
880
|
+
if (!optionsArg.prepared) refuseUnpreparedCollection(optionsArg, 'with an owned SmartData session');
|
|
881
|
+
return await sessionArg.withTransaction(writeArg, transactionOptionsOf(timeoutMS, optionsArg.signal)) as TResult;
|
|
882
|
+
}
|
|
883
|
+
const deadline = new SmartdataOperationDeadline(timeoutMS, optionsArg.signal);
|
|
884
|
+
if (!optionsArg.prepared) {
|
|
885
|
+
if (!optionsArg.prepare) refuseUnpreparedCollection(optionsArg, 'inside a transaction');
|
|
886
|
+
await deadline.join(optionsArg.prepare!());
|
|
887
|
+
}
|
|
888
|
+
const transactionOptions = transactionOptionsOf(deadline.remainingTimeoutMS(), optionsArg.signal);
|
|
889
|
+
const session = createSmartdataSession(ownerArg);
|
|
890
|
+
try {
|
|
891
|
+
return await revisionTransactionScope.run(
|
|
892
|
+
{ owner: ownerArg, session },
|
|
893
|
+
() => session.withTransaction(writeArg, transactionOptions),
|
|
894
|
+
) as TResult;
|
|
895
|
+
} finally {
|
|
896
|
+
// A session whose end fails stays owned and invalidated; the database's close() ends it again.
|
|
897
|
+
await session.close().catch(() => {});
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
783
901
|
/**
|
|
784
902
|
* Runs one model operation on the ordinary session boundary: the owned
|
|
785
903
|
* session lease, the operation's client deadline and signal, and the mapping
|
|
786
904
|
* of an expired driver deadline to the `timeout` persistence error. A write
|
|
787
905
|
* that fails once started may still have applied, so it changes every open
|
|
788
|
-
* read set that read its collection.
|
|
906
|
+
* read set that read its collection. A write of a database with write
|
|
907
|
+
* revisions runs in an owned transaction; outside one it starts its own.
|
|
789
908
|
*/
|
|
790
909
|
export const runWithOrdinarySmartdataSession = async <TResult>(
|
|
791
910
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
@@ -797,6 +916,18 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
|
|
|
797
916
|
) => Promise<TResult>,
|
|
798
917
|
): Promise<TResult> => {
|
|
799
918
|
optionsArg.signal?.throwIfAborted();
|
|
919
|
+
if (optionsArg.ordinaryWrite && needsSmartdataRevisionTransaction(sessionArg, ownerArg)) {
|
|
920
|
+
return runSmartdataRevisionTransaction(
|
|
921
|
+
sessionArg as SmartdataSession | undefined,
|
|
922
|
+
ownerArg,
|
|
923
|
+
optionsArg,
|
|
924
|
+
(transactionSessionArg) => runWithOrdinarySmartdataSession(transactionSessionArg, ownerArg, {
|
|
925
|
+
ordinaryWrite: true,
|
|
926
|
+
prepared: true,
|
|
927
|
+
...(optionsArg.collectionName === undefined ? {} : { collectionName: optionsArg.collectionName }),
|
|
928
|
+
}, operationArg),
|
|
929
|
+
);
|
|
930
|
+
}
|
|
800
931
|
const lease = leaseOrdinarySmartdataSession(sessionArg, ownerArg, optionsArg);
|
|
801
932
|
let deadline: SmartdataOperationDeadline | undefined;
|
|
802
933
|
let writeStarted = false;
|
|
@@ -834,6 +965,41 @@ const bufferTransactionWrite = (
|
|
|
834
965
|
}
|
|
835
966
|
};
|
|
836
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Adds write-revision counters to the current attempt of an owned transaction.
|
|
970
|
+
* A write that settles after its attempt ended rolls back with that attempt.
|
|
971
|
+
*/
|
|
972
|
+
const bumpTransactionRevisions = (
|
|
973
|
+
bindingArg: ISmartdataSessionBinding,
|
|
974
|
+
idsArg: Iterable<string>,
|
|
975
|
+
): void => {
|
|
976
|
+
const attempt = transactionContext.getStore()?.attempt;
|
|
977
|
+
if (attempt === undefined || attempt !== bindingArg.transactionAttempt) return;
|
|
978
|
+
for (const id of idsArg) bindingArg.revisionBumps.add(id);
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Reads, in the write's transaction, the write-revision partitions of the
|
|
983
|
+
* documents a write by selector is about to change, and returns whether it
|
|
984
|
+
* did; the write's report then states `previousPartitionsRead`. `false` when
|
|
985
|
+
* the database keeps no revisions or the write runs outside an owned
|
|
986
|
+
* transaction.
|
|
987
|
+
*/
|
|
988
|
+
export const readPreviousWriteRevisions = async (
|
|
989
|
+
ownerArg: SmartdataDb,
|
|
990
|
+
collectionArg: plugins.mongodb.Collection<any>,
|
|
991
|
+
rawSessionArg: plugins.mongodb.ClientSession | undefined,
|
|
992
|
+
selectorArg: unknown,
|
|
993
|
+
deadlineArg: SmartdataOperationDeadline,
|
|
994
|
+
): Promise<boolean> => {
|
|
995
|
+
if (!rawSessionArg || !isSmartdataWriteRevisionsActive(ownerArg)) return false;
|
|
996
|
+
const binding = bindingsByRawSession.get(rawSessionArg);
|
|
997
|
+
if (!binding?.transactionActive || binding.owner !== ownerArg) return false;
|
|
998
|
+
const ids = await readPreviousRevisionIds(ownerArg, collectionArg, rawSessionArg, selectorArg, deadlineArg);
|
|
999
|
+
bumpTransactionRevisions(binding, ids);
|
|
1000
|
+
return true;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
837
1003
|
/**
|
|
838
1004
|
* Reports one acknowledged write to the owner's write observers and open read
|
|
839
1005
|
* sets: at once, or, inside an owned transaction, after it committed. A
|
|
@@ -846,6 +1012,7 @@ export const reportSmartdataWrite = (
|
|
|
846
1012
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
847
1013
|
writeArg: ISmartdataWrite,
|
|
848
1014
|
): void => {
|
|
1015
|
+
sessionArg ??= revisionTransactionSessionOf(ownerArg);
|
|
849
1016
|
if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) {
|
|
850
1017
|
// A raw driver transaction passed its lease while nobody observed or
|
|
851
1018
|
// tracked, and whether it commits is never known here.
|
|
@@ -854,6 +1021,12 @@ export const reportSmartdataWrite = (
|
|
|
854
1021
|
}
|
|
855
1022
|
const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
|
|
856
1023
|
const buffered = binding?.transactionActive === true;
|
|
1024
|
+
if (binding && buffered) {
|
|
1025
|
+
const field = writeRevisionsPartitionField(ownerArg);
|
|
1026
|
+
if (field !== undefined && isSmartdataWriteRevisionsActive(ownerArg)) {
|
|
1027
|
+
bumpTransactionRevisions(binding, writeRevisionIdsOfWrite(writeArg, field));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
857
1030
|
const changeReads = captureSmartdataReadChange(ownerArg, writeArg, buffered);
|
|
858
1031
|
const deliver = captureSmartdataWrite(ownerArg, writeArg);
|
|
859
1032
|
if (!changeReads && !deliver) return;
|
|
@@ -875,8 +1048,12 @@ export const reportSmartdataWriteFailure = (
|
|
|
875
1048
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
876
1049
|
collectionNameArg: string,
|
|
877
1050
|
): void => {
|
|
1051
|
+
sessionArg ??= revisionTransactionSessionOf(ownerArg);
|
|
878
1052
|
const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
|
|
879
1053
|
const buffered = binding?.transactionActive === true;
|
|
1054
|
+
if (binding && buffered && isSmartdataWriteRevisionsActive(ownerArg)) {
|
|
1055
|
+
bumpTransactionRevisions(binding, [sharedRevisionId(collectionNameArg)]);
|
|
1056
|
+
}
|
|
880
1057
|
const changeReads = captureSmartdataCollectionChange(ownerArg, collectionNameArg, buffered);
|
|
881
1058
|
if (!changeReads) return;
|
|
882
1059
|
if (binding && buffered) {
|
|
@@ -898,7 +1075,7 @@ export const reportSmartdataCollectionWrite = (
|
|
|
898
1075
|
kindArg: TSmartdataWriteKind,
|
|
899
1076
|
documentsArg: readonly object[] | null,
|
|
900
1077
|
selectorArg?: unknown,
|
|
901
|
-
readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update'>,
|
|
1078
|
+
readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update' | 'previousPartitionsRead'>,
|
|
902
1079
|
): void => {
|
|
903
1080
|
const schema = collectionArg.getBoundModelSchema();
|
|
904
1081
|
reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
|
|
@@ -923,43 +1100,49 @@ const identityFieldsOfSchema = (
|
|
|
923
1100
|
/**
|
|
924
1101
|
* Registers one read of a model collection with the open read sets of the
|
|
925
1102
|
* current async context, right before it is issued; `undefined` when none is
|
|
926
|
-
* open. See `observeSmartdataRead`.
|
|
1103
|
+
* open. See `observeSmartdataRead`. Settles once the write revisions the read
|
|
1104
|
+
* depends on were read, within `deadlineArg`; issue the read after it.
|
|
927
1105
|
*/
|
|
928
1106
|
export const trackSmartdataRead = (
|
|
929
1107
|
collectionArg: SmartdataCollection<any>,
|
|
930
1108
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
931
1109
|
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
|
|
932
|
-
|
|
1110
|
+
deadlineArg?: SmartdataOperationDeadline,
|
|
1111
|
+
): Promise<ISmartdataReadRecorder | undefined> => trackSmartdataModelRead(
|
|
933
1112
|
collectionArg.smartdataDb,
|
|
934
1113
|
collectionArg.collectionName,
|
|
935
1114
|
collectionArg.getBoundModelSchema(),
|
|
936
1115
|
sessionArg,
|
|
937
1116
|
specArg,
|
|
1117
|
+
deadlineArg,
|
|
938
1118
|
);
|
|
939
1119
|
|
|
940
1120
|
/** `trackSmartdataRead` for a read that names its model by schema rather than collection. */
|
|
941
|
-
export const trackSmartdataModelRead = (
|
|
1121
|
+
export const trackSmartdataModelRead = async (
|
|
942
1122
|
dbArg: SmartdataDb,
|
|
943
1123
|
collectionNameArg: string,
|
|
944
1124
|
schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
|
|
945
1125
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
946
1126
|
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
|
|
947
|
-
|
|
1127
|
+
deadlineArg?: SmartdataOperationDeadline,
|
|
1128
|
+
): Promise<ISmartdataReadRecorder | undefined> => {
|
|
948
1129
|
if (!hasSmartdataReadScope()) return undefined;
|
|
949
1130
|
let inTransaction: boolean;
|
|
950
1131
|
try {
|
|
951
|
-
inTransaction = isOrdinarySessionInTransaction(sessionArg);
|
|
1132
|
+
inTransaction = isOrdinarySessionInTransaction(sessionArg ?? revisionTransactionSessionOf(dbArg));
|
|
952
1133
|
} catch {
|
|
953
1134
|
// An ended session's read fails; a set must not count it as followed.
|
|
954
1135
|
inTransaction = true;
|
|
955
1136
|
}
|
|
956
1137
|
const identityFields = identityFieldsOfSchema(schemaArg);
|
|
957
|
-
|
|
1138
|
+
const recorder = observeSmartdataRead(dbArg, {
|
|
958
1139
|
...specArg,
|
|
959
1140
|
collection: collectionNameArg,
|
|
960
1141
|
identityFields,
|
|
961
1142
|
orderingIdentityFields: schemaArg && identityFields ? getOrderingIdentityFields(schemaArg) : undefined,
|
|
962
1143
|
}, inTransaction);
|
|
1144
|
+
if (recorder?.ready) await (deadlineArg ? deadlineArg.join(recorder.ready) : recorder.ready);
|
|
1145
|
+
return recorder;
|
|
963
1146
|
};
|
|
964
1147
|
|
|
965
1148
|
/**
|
|
@@ -3,6 +3,7 @@ import type { SmartdataDb } from './classes.db.js';
|
|
|
3
3
|
import { convertFilterForMongoDb } from './classes.doc.js';
|
|
4
4
|
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
5
5
|
import { runCursorOperation } from './classes.cursoroperation.js';
|
|
6
|
+
import { SmartdataOperationDeadline } from './classes.operationdeadline.js';
|
|
6
7
|
import {
|
|
7
8
|
acquireSmartdataSession, isSmartdataSession, trackSmartdataModelRead, type SmartdataSession,
|
|
8
9
|
} from './classes.session.js';
|
|
@@ -81,12 +82,14 @@ const readStoredDocuments = async (
|
|
|
81
82
|
let page: { documents: plugins.mongodb.Document[]; hasMore: boolean };
|
|
82
83
|
try {
|
|
83
84
|
page = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
|
|
84
|
-
const
|
|
85
|
+
const deadline = new SmartdataOperationDeadline(timeoutMS, opts.signal);
|
|
86
|
+
const tracked = await trackSmartdataModelRead(dbArg, schema.collectionName, schema, opts.session, {
|
|
85
87
|
kind: 'documents', filter, sort: { _id: 1 }, matches: skip > 0, limited: true,
|
|
86
|
-
});
|
|
88
|
+
}, deadline);
|
|
87
89
|
const cursor = mongoDbArg.collection(schema.collectionName).find(filter, {
|
|
88
90
|
sort: { _id: 1 }, skip, limit: limit + 1, batchSize: 1,
|
|
89
|
-
timeoutMS, timeoutMode: 'cursorLifetime', signal: opts.signal,
|
|
91
|
+
timeoutMS: deadline.remainingTimeoutMS(), timeoutMode: 'cursorLifetime', signal: opts.signal,
|
|
92
|
+
session: lease?.rawSession,
|
|
90
93
|
});
|
|
91
94
|
return runCursorOperation(cursor, async (cursorArg) => {
|
|
92
95
|
const documents: plugins.mongodb.Document[] = [];
|
|
@@ -62,6 +62,11 @@ export interface ISmartdataWrite {
|
|
|
62
62
|
update?: unknown;
|
|
63
63
|
/** The model's identity fields, by which a selector names a document without its `_id`. */
|
|
64
64
|
identityFields?: readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* The write-revision partitions of the documents the selector matched were
|
|
67
|
+
* read in the write's transaction before it ran.
|
|
68
|
+
*/
|
|
69
|
+
previousPartitionsRead?: true;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
interface IWriteObserver {
|