@harperfast/harper 5.2.4 → 5.2.6
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/bin/restart.ts +48 -23
- package/bin/restartExitWatchdog.ts +99 -0
- package/bin/run.ts +16 -10
- package/components/status/crossThread.ts +69 -44
- package/components/status/types.ts +1 -0
- package/dist/bin/restart.js +39 -21
- package/dist/bin/restart.js.map +1 -1
- package/dist/bin/restartExitWatchdog.d.ts +5 -0
- package/dist/bin/restartExitWatchdog.js +100 -0
- package/dist/bin/restartExitWatchdog.js.map +1 -0
- package/dist/bin/run.d.ts +2 -0
- package/dist/bin/run.js +17 -10
- package/dist/bin/run.js.map +1 -1
- package/dist/components/status/crossThread.d.ts +1 -0
- package/dist/components/status/crossThread.js +57 -41
- package/dist/components/status/crossThread.js.map +1 -1
- package/dist/components/status/types.d.ts +1 -0
- package/dist/resources/DatabaseTransaction.d.ts +46 -1
- package/dist/resources/DatabaseTransaction.js +173 -9
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +2 -0
- package/dist/resources/LMDBTransaction.js +7 -1
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/Resource.js +5 -4
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +20 -0
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.js +56 -9
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +11 -0
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +153 -51
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/transaction.js +4 -2
- package/dist/resources/transaction.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +2 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +2 -0
- package/dist/server/threads/manageThreads.js +37 -5
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/utility/processManagement/processManagement.js +21 -3
- package/dist/utility/processManagement/processManagement.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/resources/DESIGN.md +19 -18
- package/resources/DatabaseTransaction.ts +187 -17
- package/resources/LMDBTransaction.ts +7 -1
- package/resources/Resource.ts +6 -5
- package/resources/ResourceInterface.ts +20 -0
- package/resources/Table.ts +60 -8
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +153 -54
- package/resources/transaction.ts +5 -3
- package/schema.graphql +8 -3
- package/server/DESIGN.md +6 -0
- package/server/itc/serverHandlers.js +2 -1
- package/server/threads/manageThreads.js +34 -5
- package/studio/web/assets/{Chat-BvOlYIhe.js → Chat-BNHY84-Z.js} +1 -1
- package/studio/web/assets/{FloatingChat-fCJiJCH2.js → FloatingChat-BbztUci0.js} +3 -3
- package/studio/web/assets/{apiToken-bKwPv0uN.js → apiToken-D0x_S8Wq.js} +1 -1
- package/studio/web/assets/{applications-BZ47njJ_.js → applications-BU83fSEG.js} +1 -1
- package/studio/web/assets/{index-DF1fSnXV.js → index-BQB9R8Ug.js} +4 -4
- package/studio/web/assets/{index.lazy-DWt8ubrr.js → index.lazy-CtErm5q7.js} +3 -3
- package/studio/web/assets/{notifications-B5ZBOni1.js → notifications-B8rDagJM.js} +1 -1
- package/studio/web/assets/{notifications-gVx0lA8H.js → notifications-CPwYKDDH.js} +1 -1
- package/studio/web/assets/{profile-D7g0x28A.js → profile-D0hB1xdm.js} +1 -1
- package/studio/web/assets/{regions-DRkhlkiD.js → regions-y_jxsBpN.js} +1 -1
- package/studio/web/assets/{setComponentFile-DQkkobG5.js → setComponentFile-DSHbURTI.js} +1 -1
- package/studio/web/assets/{setup-nf-EOheW.js → setup-J5SrM1Kp.js} +1 -1
- package/studio/web/assets/{status-B2tOM3RF.js → status-IoJkDzlx.js} +1 -1
- package/studio/web/assets/{swagger-ui-react-UktCouQ0.js → swagger-ui-react-BlgSS25O.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-alqAn3dT.js → useEntityRestURL-qfahJnvr.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/processManagement/processManagement.js +19 -3
|
@@ -16,6 +16,8 @@ import type { Entry } from './RecordEncoder.ts';
|
|
|
16
16
|
import { toBufferKey } from 'ordered-binary';
|
|
17
17
|
|
|
18
18
|
const trackedTxns = new Set<DatabaseTransaction>();
|
|
19
|
+
// Read options for a rotated generation's native transactions; shared because they never vary.
|
|
20
|
+
const SNAPSHOT_FREE = Object.freeze({ disableSnapshot: true });
|
|
19
21
|
// Logical transactions the monitor supervises for their WRITES, kept apart from trackedTxns because the
|
|
20
22
|
// two have different units and different consumers: trackedTxns is per-link, bounds a read snapshot, and
|
|
21
23
|
// is what the read-queue-depth metric counts, while this holds one entry per logical transaction — the
|
|
@@ -259,7 +261,9 @@ export type TransactionWrite = {
|
|
|
259
261
|
before?: () => void | Promise<void>;
|
|
260
262
|
beforeIntermediate?: () => void | Promise<void>;
|
|
261
263
|
commit?: (txnTime: number, existingEntry: Partial<Entry>, retry: boolean, transaction: any) => MaybePromise<void>;
|
|
262
|
-
|
|
264
|
+
// Once a write has been taken over, the transaction committing it is not the one that staged it, and
|
|
265
|
+
// overload accounting, the replay marker and a no-op write's removal all belong to the committer.
|
|
266
|
+
validate?: (txnTime: number, committedBy: DatabaseTransaction) => void;
|
|
263
267
|
fullUpdate?: boolean;
|
|
264
268
|
saved?: boolean;
|
|
265
269
|
deferSave?: boolean;
|
|
@@ -276,6 +280,10 @@ export type TransactionWrite = {
|
|
|
276
280
|
// sticky: a non-isRetry staging of this write appended its audit entry (set in save(); the retry
|
|
277
281
|
// dedup guards in the commit handler read it to ignore the write's own orphaned entry)
|
|
278
282
|
appendedAuditEntry?: boolean;
|
|
283
|
+
// the transaction holding this write in its `writes` (set in addWrite). A deferred write's save() is
|
|
284
|
+
// only its trigger, so it can be triggered after the context has moved on to another transaction;
|
|
285
|
+
// this is who commits it when the transaction current at that point is not a scope (#2292).
|
|
286
|
+
stagedIn?: DatabaseTransaction;
|
|
279
287
|
// the preceding write to the same store and key in this transaction, if any (linked in addWrite)
|
|
280
288
|
priorWrite?: TransactionWrite;
|
|
281
289
|
// set only by a write that BOTH reads priorStagedWrite() and publishes stagedEntry; addWrite orders
|
|
@@ -331,6 +339,15 @@ type RocksTransactionWithRetry = RocksTransaction & { isRetry?: boolean };
|
|
|
331
339
|
|
|
332
340
|
export class DatabaseTransaction implements Transaction {
|
|
333
341
|
#context: Context;
|
|
342
|
+
// Whether a resources/transaction.ts scope owns this instance — i.e. a final commit or abort is
|
|
343
|
+
// guaranteed to follow. Only such a transaction may be rotated to a new generation by a mid-scope
|
|
344
|
+
// commit (see rotateAfterMidScopeCommit); anything else must commit each later write immediately,
|
|
345
|
+
// because nothing would commit staged ones. Settable only at construction, so it cannot be turned on
|
|
346
|
+
// for a transaction that is already attached to a context and running.
|
|
347
|
+
#scopeOwned: boolean;
|
|
348
|
+
constructor(options?: { scopeOwned?: boolean }) {
|
|
349
|
+
this.#scopeOwned = options?.scopeOwned === true;
|
|
350
|
+
}
|
|
334
351
|
writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
|
|
335
352
|
// the last staged write per store and key, used to chain repeat writes to the same key (linkWrite)
|
|
336
353
|
declare writesByKey?: Map<any, Map<unknown, TransactionWrite>>;
|
|
@@ -350,6 +367,11 @@ export class DatabaseTransaction implements Transaction {
|
|
|
350
367
|
timeoutBudget = 0;
|
|
351
368
|
// save() only stages here; ImmediateTransaction overrides it to commit, which addWrite must not defer
|
|
352
369
|
saveCommits = false;
|
|
370
|
+
// True where save() puts the write into this transaction's native handle, which is what lets a scope
|
|
371
|
+
// take over a write staged in another transaction's `writes` (Table.ts's #saveOperation).
|
|
372
|
+
// LMDBTransaction's save() is a no-op — its commit applies `writes` — so there a write can only be
|
|
373
|
+
// committed by the transaction that holds it.
|
|
374
|
+
stagesWriteOnSave = true;
|
|
353
375
|
validated = 0;
|
|
354
376
|
timestamp = 0;
|
|
355
377
|
retries = 0;
|
|
@@ -391,6 +413,11 @@ export class DatabaseTransaction implements Transaction {
|
|
|
391
413
|
// Set once the retained read handle's write intents have been released (see commit()'s
|
|
392
414
|
// outstanding-iterators branch), so a retry round cannot re-fire the release.
|
|
393
415
|
declare writesAbandoned?: boolean;
|
|
416
|
+
// Set once a mid-scope commit has rotated this instance to a new generation: every native
|
|
417
|
+
// transaction it opens from then on reads WITHOUT a snapshot. Committing mid-scope is how a handler
|
|
418
|
+
// asks to stop reading a pinned snapshot, so re-pinning one for the rest of the scope would take
|
|
419
|
+
// back what it asked for.
|
|
420
|
+
snapshotFree = false;
|
|
394
421
|
|
|
395
422
|
getReadTxn(disableSnapshot?: boolean): ReadTransaction {
|
|
396
423
|
this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
|
|
@@ -417,7 +444,12 @@ export class DatabaseTransaction implements Transaction {
|
|
|
417
444
|
// snapshot that blocks compaction. Only applied when creating the transaction fresh; an
|
|
418
445
|
// already-open transaction keeps whatever snapshot mode it was created with.
|
|
419
446
|
// `coordinatedRetry` signals IsBusy write conflicts as RETRY_NOW rather than ERR_BUSY.
|
|
420
|
-
this.attachOwnedTransaction(
|
|
447
|
+
this.attachOwnedTransaction(
|
|
448
|
+
new RocksTransaction(this.db.store, {
|
|
449
|
+
coordinatedRetry: true,
|
|
450
|
+
disableSnapshot: disableSnapshot || this.snapshotFree,
|
|
451
|
+
})
|
|
452
|
+
);
|
|
421
453
|
|
|
422
454
|
if (this.timestamp) {
|
|
423
455
|
this.transaction.setTimestamp(this.timestamp);
|
|
@@ -569,11 +601,47 @@ export class DatabaseTransaction implements Transaction {
|
|
|
569
601
|
writesForStore.set(keyId, operation);
|
|
570
602
|
}
|
|
571
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Drop a staged write from this transaction, and from its per-key chain, so the transaction taking it
|
|
606
|
+
* over becomes its only owner (Table.ts's #saveOperation).
|
|
607
|
+
*/
|
|
608
|
+
detachWrite(operation: TransactionWrite): void {
|
|
609
|
+
const index = this.writes.indexOf(operation);
|
|
610
|
+
if (index > -1) this.writes[index] = null;
|
|
611
|
+
if (operation.key === undefined) return;
|
|
612
|
+
const writesForStore = this.writesByKey?.get(operation.store);
|
|
613
|
+
if (!writesForStore) return;
|
|
614
|
+
const keyId = writeKeyId(operation.key);
|
|
615
|
+
// Membership, not `stagedIn`, which every commit handler clears and so cannot tell a takeover from a
|
|
616
|
+
// write done in place; a prior already taken over must not become this transaction's basis again.
|
|
617
|
+
let prior = operation.priorWrite;
|
|
618
|
+
while (prior && !this.writes.includes(prior)) prior = prior.priorWrite;
|
|
619
|
+
const tail = writesForStore.get(keyId);
|
|
620
|
+
if (tail === operation) {
|
|
621
|
+
if (prior) writesForStore.set(keyId, prior);
|
|
622
|
+
else writesForStore.delete(keyId);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// A successor left chained to it would take its merge basis and index diff from a record another
|
|
626
|
+
// transaction owns and may roll back (harper#1968's failure class).
|
|
627
|
+
for (let successor = tail; successor; successor = successor.priorWrite) {
|
|
628
|
+
if (successor.priorWrite === operation) {
|
|
629
|
+
successor.priorWrite = prior;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
572
635
|
/**
|
|
573
636
|
* Discard the staged write set (committed or aborted); the per-key chain must go with it so a
|
|
574
637
|
* reused transaction never bases a write on a previous batch's staged state.
|
|
575
638
|
*/
|
|
576
639
|
clearWrites(): void {
|
|
640
|
+
// A deferred write's `stagedIn` must not outlive this transaction's ability to commit it: save() can
|
|
641
|
+
// fire after a commit or abort, and routing it back here would revive a write this transaction
|
|
642
|
+
// already rolled back — whose blobs abort() has reclaimed. Cleared here, save() resolves the
|
|
643
|
+
// context's current transaction as it did before `stagedIn` existed.
|
|
644
|
+
for (const write of this.writes) if (write?.stagedIn === this) write.stagedIn = undefined;
|
|
577
645
|
this.writes = [];
|
|
578
646
|
this.writesByKey = undefined;
|
|
579
647
|
}
|
|
@@ -708,6 +776,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
708
776
|
this.writeTimeout = this.timeout;
|
|
709
777
|
this.linkWrite(operation);
|
|
710
778
|
this.writes.push(operation);
|
|
779
|
+
operation.stagedIn = this;
|
|
711
780
|
// Hold this write back while any earlier same-key write has not run — out of staging order both
|
|
712
781
|
// diff against the pre-transaction record (harper#2211, DESIGN.md). The whole chain, not just the
|
|
713
782
|
// immediate link: an eager non-chaining write in between would otherwise launder the deferral.
|
|
@@ -743,7 +812,10 @@ export class DatabaseTransaction implements Transaction {
|
|
|
743
812
|
if (!transaction && this.open === TRANSACTION_STATE.OPEN) transaction = this.transaction;
|
|
744
813
|
let immediateCommit = false;
|
|
745
814
|
if (!transaction) {
|
|
746
|
-
transaction = new RocksTransaction(
|
|
815
|
+
transaction = new RocksTransaction(
|
|
816
|
+
operation.store.store as RocksStore,
|
|
817
|
+
this.snapshotFree ? SNAPSHOT_FREE : undefined
|
|
818
|
+
);
|
|
747
819
|
if (operation.store.rootStore !== this.db.rootStore) {
|
|
748
820
|
harperLogger.warn?.('Created new transaction in save, but the store does match existing store', transaction.id);
|
|
749
821
|
}
|
|
@@ -783,7 +855,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
783
855
|
if (!operation.saved) {
|
|
784
856
|
operation.saved = true;
|
|
785
857
|
// immediately execute in this transaction
|
|
786
|
-
if ((operation.validate?.(txnTime) as any) === false) {
|
|
858
|
+
if ((operation.validate?.(txnTime, this) as any) === false) {
|
|
787
859
|
operation.commit = () => {}; // noop if we try again
|
|
788
860
|
return;
|
|
789
861
|
}
|
|
@@ -831,6 +903,13 @@ export class DatabaseTransaction implements Transaction {
|
|
|
831
903
|
// check just in case we got any more transactions while we were waiting, if so just recursively continue to finish the additional writes now
|
|
832
904
|
return this.commit(options);
|
|
833
905
|
}
|
|
906
|
+
// The save loop above can be what opened this transaction's native handle — save() attaches
|
|
907
|
+
// one when it had none, which is every ImmediateTransaction commit since its getReadTxn
|
|
908
|
+
// opens none — leaving the local captured before the loop empty while that handle holds
|
|
909
|
+
// every staged write, for the detach below to drop uncommitted (issue #2288). Only when
|
|
910
|
+
// the local is empty: a truthy one is what the loop staged into, and the retained-handle
|
|
911
|
+
// and replay branches below deliberately commit a handle other than this.transaction.
|
|
912
|
+
if (!transaction) transaction = this.transaction;
|
|
834
913
|
this.open = TRANSACTION_STATE.CLOSED;
|
|
835
914
|
// RocksTransaction.commit() resolves with RETRY_NOW_VALUE (a number) under
|
|
836
915
|
// coordinatedRetry, or void on a normal commit/abort.
|
|
@@ -1024,15 +1103,27 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1024
1103
|
// now reset transactions tracking; this transaction be reused and committed again
|
|
1025
1104
|
this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
|
|
1026
1105
|
this.clearWrites();
|
|
1106
|
+
if (options.doneWriting) this.endScopeOwnership();
|
|
1027
1107
|
this.releaseContext(!!options.doneWriting);
|
|
1028
|
-
this.next = null;
|
|
1029
1108
|
let txnTime = this.timestamp;
|
|
1030
1109
|
this.timestamp = 0; // reset the timestamp as well
|
|
1031
|
-
return Promise.all(completions).then(
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1110
|
+
return Promise.all(completions).then(
|
|
1111
|
+
() => {
|
|
1112
|
+
// Only once the chained store's commit has settled, as on the synchronous path: a
|
|
1113
|
+
// partially failed mid-scope commit must not leave the scope resumable.
|
|
1114
|
+
this.completeMidScopeCommit(options);
|
|
1115
|
+
return {
|
|
1116
|
+
txnTime,
|
|
1117
|
+
};
|
|
1118
|
+
},
|
|
1119
|
+
(error) => {
|
|
1120
|
+
// As on the synchronous path: a completion that failed (a chained store's commit,
|
|
1121
|
+
// a replication confirmation) leaves this commit partly landed, so ownership goes
|
|
1122
|
+
// with it rather than letting a later commit rotate on top.
|
|
1123
|
+
this.endScopeOwnership();
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
);
|
|
1036
1127
|
},
|
|
1037
1128
|
(error) => {
|
|
1038
1129
|
// Coordinated transactions surface conflicts as RETRY_NOW (handled in the
|
|
@@ -1110,6 +1201,9 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1110
1201
|
// back-reference here too, or transaction.ts's onComplete() (which has no
|
|
1111
1202
|
// rejection handler of its own) would leave a long-lived context pinning this
|
|
1112
1203
|
// CLOSED wrapper forever.
|
|
1204
|
+
// A failed commit must never be followed by a resumed segment: this generation is
|
|
1205
|
+
// finished and its durability is unknown, so ownership goes with it.
|
|
1206
|
+
this.endScopeOwnership();
|
|
1113
1207
|
this.releaseContext(!!options.doneWriting);
|
|
1114
1208
|
throw error;
|
|
1115
1209
|
}
|
|
@@ -1121,6 +1215,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1121
1215
|
cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
|
|
1122
1216
|
}
|
|
1123
1217
|
this.clearWrites();
|
|
1218
|
+
if (options.doneWriting) this.endScopeOwnership();
|
|
1124
1219
|
this.releaseContext(!!options.doneWriting);
|
|
1125
1220
|
const txnResolution: CommitResolution = {
|
|
1126
1221
|
txnTime: this.timestamp,
|
|
@@ -1129,16 +1224,39 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1129
1224
|
// now run any other transactions
|
|
1130
1225
|
options.timestamp = this.timestamp;
|
|
1131
1226
|
// as above: the next store must not inherit this store's explicit native transaction
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1227
|
+
let nextResolution;
|
|
1228
|
+
try {
|
|
1229
|
+
nextResolution = this.next?.commit(options.transaction ? { ...options, transaction: undefined } : options);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
// A synchronous throw reaches neither rejection handler below, and the head has already
|
|
1232
|
+
// committed — surrender ownership here too, or the scope stays resumable on top of a
|
|
1233
|
+
// half-landed multi-store commit.
|
|
1234
|
+
this.endScopeOwnership();
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1135
1237
|
if ((nextResolution as any)?.then)
|
|
1136
|
-
return (nextResolution as any)?.then(
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1238
|
+
return (nextResolution as any)?.then(
|
|
1239
|
+
(nextResolution) => {
|
|
1240
|
+
// Only once the chained store's own commit has SETTLED: rotating first would leave the
|
|
1241
|
+
// scope resumable after a partially failed mid-scope commit.
|
|
1242
|
+
this.completeMidScopeCommit(options);
|
|
1243
|
+
return {
|
|
1244
|
+
txnTime: this.timestamp,
|
|
1245
|
+
next: nextResolution,
|
|
1246
|
+
};
|
|
1247
|
+
},
|
|
1248
|
+
(error) => {
|
|
1249
|
+
// A chained store's commit failed, so this multi-store commit half-landed. Surrender
|
|
1250
|
+
// ownership as the head's own failure branch does: a handler that catches this and
|
|
1251
|
+
// commits again must not rotate on top of it, and must not have the failed link
|
|
1252
|
+
// dropped from the chain before its abort can clean up its blobs.
|
|
1253
|
+
this.endScopeOwnership();
|
|
1254
|
+
throw error;
|
|
1255
|
+
}
|
|
1256
|
+
);
|
|
1140
1257
|
txnResolution.next = nextResolution as any;
|
|
1141
1258
|
}
|
|
1259
|
+
this.completeMidScopeCommit(options);
|
|
1142
1260
|
return txnResolution;
|
|
1143
1261
|
},
|
|
1144
1262
|
(error) => {
|
|
@@ -1147,6 +1265,44 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1147
1265
|
}
|
|
1148
1266
|
);
|
|
1149
1267
|
}
|
|
1268
|
+
/**
|
|
1269
|
+
* A successful commit that is NOT the scope's final one leaves the scope still running and still
|
|
1270
|
+
* responsible for a commit. Rotate to a fresh OPEN generation so the rest of the scope's writes
|
|
1271
|
+
* stage into it and are committed — or rolled back — as one unit, instead of each committing itself
|
|
1272
|
+
* the moment it is made. Every dispatch path keeps its plain `open === OPEN` check; CLOSED never
|
|
1273
|
+
* gains a second meaning.
|
|
1274
|
+
*
|
|
1275
|
+
* Deliberately not rotated when: the scope is finished (`doneWriting`), nothing owns this instance,
|
|
1276
|
+
* a timeout poisoned it, or a commit failed — a failed or uncertain commit must never be followed by
|
|
1277
|
+
* a resumed segment that can commit on its own. Nor when read iterators still hold the native
|
|
1278
|
+
* handle: that handle belongs to them until they drain, so there is nothing to rotate into and those
|
|
1279
|
+
* writes keep today's immediate-commit path.
|
|
1280
|
+
*/
|
|
1281
|
+
/**
|
|
1282
|
+
* Finish a commit: the chain goes with it, then the scope may rotate. A link left attached and CLOSED
|
|
1283
|
+
* would be reused by txnForContext for the next write to that database and commit itself, surviving a
|
|
1284
|
+
* rollback of the rotated head — the cross-store leftover this rotation exists to prevent. Every
|
|
1285
|
+
* commit path must run this, and none may do one half without the other.
|
|
1286
|
+
*/
|
|
1287
|
+
/** Both scope flags leave together, so no exit can clear one and keep the other. */
|
|
1288
|
+
private endScopeOwnership(): void {
|
|
1289
|
+
this.#scopeOwned = false;
|
|
1290
|
+
this.snapshotFree = false;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
private completeMidScopeCommit(options: CommitOptions): void {
|
|
1294
|
+
this.next = null;
|
|
1295
|
+
this.rotateAfterMidScopeCommit(options);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** See completeMidScopeCommit, which is the only caller and carries the reasoning. */
|
|
1299
|
+
private rotateAfterMidScopeCommit(options: CommitOptions): void {
|
|
1300
|
+
if (options.doneWriting || this.timedOut || this.transaction || !this.#scopeOwned) return;
|
|
1301
|
+
this.open = TRANSACTION_STATE.OPEN;
|
|
1302
|
+
this.snapshotFree = true;
|
|
1303
|
+
this.writesAbandoned = false;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1150
1306
|
abort(): void {
|
|
1151
1307
|
while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
|
|
1152
1308
|
// Defensively release any native handle whose reference bookkeeping was already consumed.
|
|
@@ -1159,6 +1315,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1159
1315
|
cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
|
|
1160
1316
|
}
|
|
1161
1317
|
} finally {
|
|
1318
|
+
this.endScopeOwnership(); // the scope is over; nothing may rotate this instance again
|
|
1162
1319
|
this.clearWrites();
|
|
1163
1320
|
// A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
|
|
1164
1321
|
// Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
|
|
@@ -1382,6 +1539,19 @@ export function isReleasedTransaction(value: unknown): boolean {
|
|
|
1382
1539
|
return value === RELEASED_TRANSACTION;
|
|
1383
1540
|
}
|
|
1384
1541
|
|
|
1542
|
+
/**
|
|
1543
|
+
* Whether this transaction can be joined as the atomic scope resources/transaction.ts promises. OPEN is
|
|
1544
|
+
* not sufficient: an ImmediateTransaction commits every write as it is made, so a caller that joined one
|
|
1545
|
+
* would get per-write autocommit with no final commit or abort to roll back to. txnForContext installs
|
|
1546
|
+
* one in a context slot that is empty or holds the released placeholder, where it reports OPEN with
|
|
1547
|
+
* nothing owning a commit for it (#2292). Ownership itself is deliberately not the test — a context
|
|
1548
|
+
* pre-seeded with an externally driven DatabaseTransaction (replayLogs.ts) still owns the writes it is
|
|
1549
|
+
* given, and its own commit/abort still governs them.
|
|
1550
|
+
*/
|
|
1551
|
+
export function isJoinableScope(transaction: DatabaseTransaction | null | undefined): boolean {
|
|
1552
|
+
return transaction?.open === TRANSACTION_STATE.OPEN && !transaction.saveCommits;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1385
1555
|
let timer;
|
|
1386
1556
|
|
|
1387
1557
|
/**
|
|
@@ -25,6 +25,7 @@ export function replicationConfirmation(callback) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export class LMDBTransaction extends DatabaseTransaction {
|
|
28
|
+
stagesWriteOnSave = false;
|
|
28
29
|
#context: Context;
|
|
29
30
|
writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
|
|
30
31
|
validated = 0;
|
|
@@ -97,6 +98,9 @@ export class LMDBTransaction extends DatabaseTransaction {
|
|
|
97
98
|
const immediateTxn = new ImmediateTransaction(this.db);
|
|
98
99
|
immediateTxn.addWrite(operation);
|
|
99
100
|
const result = immediateTxn.commit({});
|
|
101
|
+
// Nothing may be sent back to this throwaway: the write is already committed, and its
|
|
102
|
+
// durability is the promise below.
|
|
103
|
+
operation.stagedIn = undefined;
|
|
100
104
|
if (result?.then) {
|
|
101
105
|
operation.promise = result;
|
|
102
106
|
} else {
|
|
@@ -107,6 +111,7 @@ export class LMDBTransaction extends DatabaseTransaction {
|
|
|
107
111
|
|
|
108
112
|
this.linkWrite(operation);
|
|
109
113
|
this.writes.push(operation); // standard path, add to current transaction
|
|
114
|
+
operation.stagedIn = this;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
removeWrite(operation: TransactionWrite) {
|
|
@@ -133,7 +138,7 @@ export class LMDBTransaction extends DatabaseTransaction {
|
|
|
133
138
|
this.validated = this.writes.length;
|
|
134
139
|
for (let i = start; i < this.validated; i++) {
|
|
135
140
|
const write = this.writes[i];
|
|
136
|
-
write?.validate?.(this.timestamp);
|
|
141
|
+
write?.validate?.(this.timestamp, this);
|
|
137
142
|
}
|
|
138
143
|
let hasBefore;
|
|
139
144
|
for (let i = start; i < this.validated; i++) {
|
|
@@ -342,6 +347,7 @@ export class LMDBTransaction extends DatabaseTransaction {
|
|
|
342
347
|
}
|
|
343
348
|
|
|
344
349
|
export class ImmediateTransaction extends LMDBTransaction {
|
|
350
|
+
saveCommits = true;
|
|
345
351
|
constructor(db: RootDatabaseKind) {
|
|
346
352
|
super();
|
|
347
353
|
this.db = db;
|
package/resources/Resource.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
import { randomUUID } from 'crypto';
|
|
13
13
|
import {
|
|
14
14
|
DatabaseTransaction,
|
|
15
|
+
isJoinableScope,
|
|
15
16
|
isReleasedTransaction,
|
|
16
|
-
TRANSACTION_STATE,
|
|
17
17
|
type Transaction,
|
|
18
18
|
} from './DatabaseTransaction.ts';
|
|
19
19
|
import { IterableEventQueue } from './IterableEventQueue.ts';
|
|
@@ -769,9 +769,10 @@ function transactional(
|
|
|
769
769
|
if (isCollection) resourceOptions.isCollection = true;
|
|
770
770
|
} else resourceOptions = options;
|
|
771
771
|
const loadAsInstance = this.loadAsInstance;
|
|
772
|
-
// Only join an existing transaction
|
|
773
|
-
//
|
|
774
|
-
//
|
|
772
|
+
// Only join an existing transaction that can actually be a scope (isJoinableScope: OPEN, and it
|
|
773
|
+
// stages its writes rather than committing each one — the same gate resources/transaction.ts's
|
|
774
|
+
// transaction() helper applies to itself). Beyond that, a `context` object can carry a *stale*
|
|
775
|
+
// `.transaction` left over from an earlier, unrelated call that
|
|
775
776
|
// already ran to completion: ambient contexts obtained via contextStorage.getStore() are
|
|
776
777
|
// no longer guaranteed to be fresh, one-shot objects now that processLocalTransaction (#1591/
|
|
777
778
|
// #1592) installs one shared, long-lived context for the lifetime of an entire operation
|
|
@@ -795,7 +796,7 @@ function transactional(
|
|
|
795
796
|
// starting fresh) makes the write throw transactionOpenTooLongError via addWrite()/commit()'s
|
|
796
797
|
// poison check, correctly propagating the abort to the caller. See
|
|
797
798
|
// integrationTests/resources/txn-overtime-atomicity.test.ts.
|
|
798
|
-
if (context?.transaction
|
|
799
|
+
if (isJoinableScope(context?.transaction) || context?.transaction?.timedOut) {
|
|
799
800
|
// we are already in a transaction (or it was poisoned by a timeout abort and must fail), proceed
|
|
800
801
|
const resource = this.getResource(query, context, resourceOptions);
|
|
801
802
|
return resource.then
|
|
@@ -75,6 +75,26 @@ export interface Context {
|
|
|
75
75
|
* so code that commits mid-handler can keep using its context. LMDBTransaction does not release,
|
|
76
76
|
* so there the completed transaction itself stays in the slot — also safe to call, but retained.
|
|
77
77
|
* `null` was the previous released marker and is still accepted defensively.
|
|
78
|
+
*
|
|
79
|
+
* A read that resolves a transaction without going through the static-API wrappers (an instance load,
|
|
80
|
+
* for one) replaces a released or never-set slot with an ImmediateTransaction, which commits every
|
|
81
|
+
* write as it is made. `transaction()` and the static API start their own scope rather than joining
|
|
82
|
+
* that, so an explicit `transaction()` is atomic on a released slot as it is on a fresh one.
|
|
83
|
+
*
|
|
84
|
+
* A transaction its own handler commits mid-scope is rotated to a fresh open generation, so the rest
|
|
85
|
+
* of that scope's writes are committed — or rolled back — with the scope's final commit rather than
|
|
86
|
+
* each committing itself immediately. Two cases keep the older per-write behavior: a commit made
|
|
87
|
+
* while a read iterator still holds the transaction's handle (that handle is the iterator's until it
|
|
88
|
+
* drains), and a commit that failed. LMDB has always behaved this way; the RocksDB path now matches.
|
|
89
|
+
*
|
|
90
|
+
* The engines still differ on reads. A rotated RocksDB generation is snapshot-free, so the rest of
|
|
91
|
+
* the scope keeps seeing other writers' committed data — which is what committing mid-scope asks for.
|
|
92
|
+
* LMDB cannot open a snapshot-free read transaction, so there the scope keeps its snapshot.
|
|
93
|
+
*
|
|
94
|
+
* Because the writes after a mid-scope commit stage rather than commit one at a time, a single
|
|
95
|
+
* mid-scope commit does not bound how much a long handler holds in memory. A handler streaming a
|
|
96
|
+
* large volume should keep committing (a checkpoint every N records), which commits each batch and
|
|
97
|
+
* rotates again.
|
|
78
98
|
*/
|
|
79
99
|
transaction?: DatabaseTransaction | null;
|
|
80
100
|
/** If the operation that will be performed with this context should check user authorization */
|
package/resources/Table.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
DatabaseTransaction,
|
|
37
37
|
ImmediateTransaction,
|
|
38
38
|
priorStagedWrite,
|
|
39
|
+
isJoinableScope,
|
|
39
40
|
isReleasedTransaction,
|
|
40
41
|
TRANSACTION_STATE,
|
|
41
42
|
writeKeyId,
|
|
@@ -1873,7 +1874,28 @@ export function makeTable(options) {
|
|
|
1873
1874
|
}
|
|
1874
1875
|
#saveOperation(operation: any) {
|
|
1875
1876
|
const transaction = txnForContext(this.getContext());
|
|
1876
|
-
|
|
1877
|
+
const holder = operation.stagedIn;
|
|
1878
|
+
// never-drop-on-conflict lives on the transaction and would not travel with the write, so an
|
|
1879
|
+
// apply or a replay keeps it (harper-pro#348)
|
|
1880
|
+
const holderOwnsPolicy = holder?.sourceApply || holder?.isReplay;
|
|
1881
|
+
// stagesWriteOnSave: LMDBTransaction's addWrite never runs the write (its commit applies
|
|
1882
|
+
// `writes`), so handing it one is a dead end
|
|
1883
|
+
if (
|
|
1884
|
+
holder &&
|
|
1885
|
+
holder !== transaction &&
|
|
1886
|
+
!holderOwnsPolicy &&
|
|
1887
|
+
transaction.stagesWriteOnSave &&
|
|
1888
|
+
isJoinableScope(transaction)
|
|
1889
|
+
) {
|
|
1890
|
+
holder.detachWrite(operation);
|
|
1891
|
+
// The basis chain belongs to the holder: derived from a write this scope cannot commit, the
|
|
1892
|
+
// merge and index diff would be relative to a record that may never land.
|
|
1893
|
+
operation.priorWrite = undefined;
|
|
1894
|
+
operation.deferSave = false;
|
|
1895
|
+
return when(transaction.addWrite(operation), () => operation.promise ?? operation.result);
|
|
1896
|
+
}
|
|
1897
|
+
const owner = holder ?? transaction;
|
|
1898
|
+
if (owner.save) return owner.save(operation) || operation.promise || operation.result;
|
|
1877
1899
|
}
|
|
1878
1900
|
|
|
1879
1901
|
addTo(property: any, value: any) {
|
|
@@ -2314,11 +2336,11 @@ export function makeTable(options) {
|
|
|
2314
2336
|
nodeName: (context as any)?.nodeName,
|
|
2315
2337
|
fullUpdate,
|
|
2316
2338
|
deferSave: true,
|
|
2317
|
-
validate: (txnTime) => {
|
|
2339
|
+
validate: (txnTime, committedBy = transaction) => {
|
|
2318
2340
|
if (!recordUpdate) recordUpdate = this.#changes;
|
|
2319
2341
|
if (fullUpdate || (recordUpdate && hasChanges(this.#changes === recordUpdate ? this : recordUpdate))) {
|
|
2320
2342
|
if (!(context as any)?.source) {
|
|
2321
|
-
|
|
2343
|
+
committedBy.checkOverloaded();
|
|
2322
2344
|
// A record must be a plain object. Reject primitive, string/number, bare-binary,
|
|
2323
2345
|
// and bare-array roots — e.g. a raw Buffer from an application/octet-stream PUT, a
|
|
2324
2346
|
// JSON string/number body, or a top-level JSON array. Such roots carry no primary
|
|
@@ -2356,7 +2378,7 @@ export function makeTable(options) {
|
|
|
2356
2378
|
// by replayLogs). Records were valid when originally written; post-crash schema
|
|
2357
2379
|
// evolution (e.g. newly required fields) must not prevent replaying them
|
|
2358
2380
|
// (harper#1316, facet b).
|
|
2359
|
-
if (!
|
|
2381
|
+
if (!committedBy.isReplay) this.validate(recordUpdate, !fullUpdate);
|
|
2360
2382
|
if (updatedTimeProperty) {
|
|
2361
2383
|
recordUpdate[updatedTimeProperty.name] =
|
|
2362
2384
|
updatedTimeProperty.type === 'Date'
|
|
@@ -2391,7 +2413,7 @@ export function makeTable(options) {
|
|
|
2391
2413
|
// TODO: else freeze after we have applied the changes
|
|
2392
2414
|
}
|
|
2393
2415
|
} else {
|
|
2394
|
-
(
|
|
2416
|
+
(committedBy as any).removeWrite?.(write);
|
|
2395
2417
|
return false;
|
|
2396
2418
|
}
|
|
2397
2419
|
},
|
|
@@ -2434,6 +2456,7 @@ export function makeTable(options) {
|
|
|
2434
2456
|
let incrementalUpdateToApply: boolean;
|
|
2435
2457
|
|
|
2436
2458
|
this.#savingOperation = null;
|
|
2459
|
+
write.stagedIn = undefined; // nothing may pin this write's transaction past its commit
|
|
2437
2460
|
let omitLocalRecord = false;
|
|
2438
2461
|
// we use optimistic locking to only commit if the existing record state still holds true.
|
|
2439
2462
|
// this is superior to using an async transaction since it doesn't require JS execution
|
|
@@ -5645,10 +5668,33 @@ export function makeTable(options) {
|
|
|
5645
5668
|
// See if this is a transaction for our database and if so, use it
|
|
5646
5669
|
if (transaction.db?.path === primaryStore.path) return transaction;
|
|
5647
5670
|
// try the next one:
|
|
5648
|
-
|
|
5671
|
+
let nextTxn = transaction.next;
|
|
5672
|
+
// A self-committing link is CLOSED once it has committed, and a further write through it
|
|
5673
|
+
// commits on a native handle nothing awaits (#2323). Spent — closed, handle detached, none of
|
|
5674
|
+
// its OWN writes left (hasPendingWrites walks successors, which is not this question) — it
|
|
5675
|
+
// holds nothing, so drop it. A run of them can be spent, hence the loop. A timeout-poisoned
|
|
5676
|
+
// link is kept: reusing it is what makes the rest of the operation fail atomically (#1411).
|
|
5677
|
+
while (
|
|
5678
|
+
nextTxn?.saveCommits &&
|
|
5679
|
+
nextTxn.open !== TRANSACTION_STATE.OPEN &&
|
|
5680
|
+
!nextTxn.timedOut &&
|
|
5681
|
+
!nextTxn.transaction &&
|
|
5682
|
+
!nextTxn.writes.some((write) => write)
|
|
5683
|
+
) {
|
|
5684
|
+
transaction.next = nextTxn.next;
|
|
5685
|
+
nextTxn = transaction.next;
|
|
5686
|
+
}
|
|
5649
5687
|
if (!nextTxn) {
|
|
5650
5688
|
// no next one, then add our database
|
|
5651
|
-
|
|
5689
|
+
// A staging link under a self-committing head is committed only if the head's own database
|
|
5690
|
+
// is written again and cascades the chain, so a handler writing this one last loses it (#2292).
|
|
5691
|
+
transaction.next = transaction.saveCommits
|
|
5692
|
+
? ((isRocksDB
|
|
5693
|
+
? new ImmediateTransaction(primaryStore as any)
|
|
5694
|
+
: new ImmediateLMDBTransaction(primaryStore as any)) as any)
|
|
5695
|
+
: isRocksDB
|
|
5696
|
+
? new DatabaseTransaction()
|
|
5697
|
+
: new LMDBTransaction();
|
|
5652
5698
|
// The chain root, so a link that only ever receives a blind write is supervised by the
|
|
5653
5699
|
// long-transaction monitor as part of its logical transaction rather than as its own
|
|
5654
5700
|
// timeout root (issue #2231).
|
|
@@ -5667,8 +5713,14 @@ export function makeTable(options) {
|
|
|
5667
5713
|
// commit is tracked with its own identity (DatabaseTransaction.ts's trackOutstandingCommit),
|
|
5668
5714
|
// so a wedged second-store commit is named just as precisely as a wedged first one.
|
|
5669
5715
|
transaction.next.startedFrom = transaction.startedFrom;
|
|
5670
|
-
|
|
5716
|
+
// A second database joined after a mid-scope commit belongs to the same snapshot-free
|
|
5717
|
+
// generation as the head, or its reads would re-pin what the commit just unpinned.
|
|
5718
|
+
transaction.next.snapshotFree = transaction.snapshotFree;
|
|
5719
|
+
if (transaction.open === TRANSACTION_STATE.CLOSED && !transaction.next.saveCommits) {
|
|
5671
5720
|
// if the current transaction is already closed, we need to retain that state on new databases we work with
|
|
5721
|
+
// Never onto a self-committing link: CLOSED is what routes its first write through the
|
|
5722
|
+
// commit re-entry that drops the native commit promise (#2323), and it commits per write
|
|
5723
|
+
// regardless of this state.
|
|
5672
5724
|
transaction.next.open = TRANSACTION_STATE.CLOSED;
|
|
5673
5725
|
}
|
|
5674
5726
|
transaction = transaction.next;
|