@harperfast/harper 5.3.0 → 5.3.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/bin/copyDb.ts +5 -1
- package/bin/deployRenderer.ts +3 -0
- package/components/Application.ts +66 -22
- package/components/DESIGN.md +89 -36
- package/components/componentLoader.ts +2 -0
- package/components/deploymentRecorder.ts +18 -1
- package/components/installFingerprint.ts +123 -0
- package/components/mcp/DESIGN.md +4 -4
- package/components/operations.js +29 -0
- package/config/DESIGN.md +5 -5
- package/dataLayer/DESIGN.md +37 -22
- package/dataLayer/backupRepository.ts +248 -0
- package/dataLayer/restoreMarker.ts +30 -75
- package/dataLayer/rocksdbBackup.ts +253 -110
- package/dist/bin/copyDb.js +3 -0
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/deployRenderer.js +3 -0
- package/dist/bin/deployRenderer.js.map +1 -1
- package/dist/components/Application.d.ts +5 -0
- package/dist/components/Application.js +51 -19
- package/dist/components/Application.js.map +1 -1
- package/dist/components/componentLoader.js +2 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentRecorder.js +18 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/installFingerprint.d.ts +39 -0
- package/dist/components/installFingerprint.js +113 -0
- package/dist/components/installFingerprint.js.map +1 -0
- package/dist/components/operations.d.ts +2 -0
- package/dist/components/operations.js +31 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/dataLayer/backupRepository.d.ts +33 -0
- package/dist/dataLayer/backupRepository.js +232 -0
- package/dist/dataLayer/backupRepository.js.map +1 -0
- package/dist/dataLayer/restoreMarker.d.ts +4 -1
- package/dist/dataLayer/restoreMarker.js +29 -68
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.js +221 -112
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +14 -0
- package/dist/resources/DatabaseTransaction.d.ts +140 -11
- package/dist/resources/DatabaseTransaction.js +1055 -464
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/IterableEventQueue.d.ts +2 -1
- package/dist/resources/IterableEventQueue.js +16 -8
- package/dist/resources/IterableEventQueue.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +13 -2
- package/dist/resources/LMDBTransaction.js +219 -93
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/RecordEncoder.js +2 -3
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +5 -6
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +3 -1
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/RocksIndexStore.d.ts +2 -1
- package/dist/resources/RocksIndexStore.js +3 -0
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/Table.js +116 -12
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -2
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +71 -17
- package/dist/resources/auditStore.js +248 -64
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/branchDatabase.js +2 -0
- package/dist/resources/branchDatabase.js.map +1 -1
- package/dist/resources/databases.js +8 -2
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
- package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
- package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
- package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
- package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -0
- package/dist/resources/models/Models.js +95 -4
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +8 -0
- package/dist/resources/models/backendRegistry.js +15 -1
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +2 -0
- package/dist/resources/models/bootstrap.js +6 -0
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/models/calibration.d.ts +88 -0
- package/dist/resources/models/calibration.js +244 -0
- package/dist/resources/models/calibration.js.map +1 -0
- package/dist/resources/models/calibrationStore.d.ts +163 -0
- package/dist/resources/models/calibrationStore.js +986 -0
- package/dist/resources/models/calibrationStore.js.map +1 -0
- package/dist/resources/models/decisionStore.d.ts +24 -0
- package/dist/resources/models/decisionStore.js +45 -0
- package/dist/resources/models/decisionStore.js.map +1 -1
- package/dist/resources/models/generativeDecision.js +44 -15
- package/dist/resources/models/generativeDecision.js.map +1 -1
- package/dist/resources/models/types.d.ts +7 -0
- package/dist/resources/recordLock.d.ts +8 -5
- package/dist/resources/recordLock.js +6 -4
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/replayLogs.js +8 -1
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/scheduler/engine.d.ts +4 -0
- package/dist/resources/scheduler/engine.js +30 -2
- package/dist/resources/scheduler/engine.js.map +1 -1
- package/dist/resources/search.d.ts +16 -4
- package/dist/resources/search.js +117 -13
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transaction.js +85 -5
- package/dist/resources/transaction.js.map +1 -1
- package/dist/resources/transactionBroadcast.d.ts +8 -0
- package/dist/resources/transactionBroadcast.js +65 -0
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/keys.d.ts +1 -1
- package/dist/security/keys.js +170 -120
- package/dist/security/keys.js.map +1 -1
- package/dist/server/http.js +10 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/Request.js +11 -2
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +8 -3
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +38 -7
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +3 -3
- package/dist/server/threads/manageThreads.js +15 -13
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/threadServer.d.ts +3 -0
- package/dist/server/threads/threadServer.js +24 -6
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.js +9 -3
- package/dist/upgrade/directives/5-3-0.js.map +1 -1
- package/dist/utility/durableFile.d.ts +36 -0
- package/dist/utility/durableFile.js +106 -0
- package/dist/utility/durableFile.js.map +1 -0
- package/dist/utility/errors/hdbError.d.ts +4 -0
- package/dist/utility/errors/hdbError.js +10 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/globalSchema.d.ts +9 -0
- package/dist/utility/hdbTerms.d.ts +1 -0
- package/dist/utility/hdbTerms.js +1 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/validation/configValidator.js +29 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +7 -0
- package/json/systemSchema.json +14 -0
- package/npm-shrinkwrap.json +22 -22
- package/package.json +2 -2
- package/resources/DESIGN.md +57 -272
- package/resources/DatabaseTransaction.ts +708 -87
- package/resources/FULL_TEXT.md +6 -2
- package/resources/IterableEventQueue.ts +14 -7
- package/resources/LMDBTransaction.ts +212 -84
- package/resources/RecordEncoder.ts +2 -3
- package/resources/Resource.ts +5 -6
- package/resources/ResourceInterface.ts +3 -1
- package/resources/RocksIndexStore.ts +5 -0
- package/resources/Table.ts +118 -15
- package/resources/analytics/write.ts +6 -2
- package/resources/audit-retention.md +203 -0
- package/resources/auditStore.ts +306 -84
- package/resources/branchDatabase.ts +2 -0
- package/resources/databases.ts +6 -2
- package/resources/indexes/DESIGN.md +120 -177
- package/resources/indexes/fullTextNativeBinding.ts +22 -3
- package/resources/indexes/fullTextQueryIndex.ts +274 -11
- package/resources/models/DESIGN.md +11 -3
- package/resources/models/Models.ts +127 -4
- package/resources/models/backendRegistry.ts +17 -0
- package/resources/models/bootstrap.ts +9 -1
- package/resources/models/calibration.ts +298 -0
- package/resources/models/calibrationStore.ts +1167 -0
- package/resources/models/decisionStore.ts +57 -0
- package/resources/models/generativeDecision.ts +54 -16
- package/resources/models/types.ts +6 -0
- package/resources/record-locks.md +155 -141
- package/resources/recordLock.ts +12 -6
- package/resources/replayLogs.ts +9 -2
- package/resources/scheduler/DESIGN.md +4 -3
- package/resources/scheduler/engine.ts +35 -2
- package/resources/search.ts +154 -16
- package/resources/transaction.ts +50 -5
- package/resources/transactionBroadcast.ts +69 -0
- package/security/DESIGN.md +9 -30
- package/security/keys.ts +175 -127
- package/server/DESIGN.md +70 -36
- package/server/http.ts +10 -0
- package/server/serverHelpers/Request.ts +10 -2
- package/server/serverHelpers/contentTypes.ts +5 -3
- package/server/serverHelpers/uwsServer.ts +34 -7
- package/server/threads/manageThreads.js +16 -14
- package/server/threads/threadServer.js +24 -6
- package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
- package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
- package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
- package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
- package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
- package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
- package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
- package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
- package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
- package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
- package/studio/web/assets/index-QLZw6xrU.css +1 -0
- package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
- package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
- package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
- package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
- package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
- package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
- package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
- package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
- package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
- package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
- package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
- package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
- package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
- package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
- package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
- package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
- package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
- package/studio/web/index.html +2 -2
- package/upgrade/directives/5-3-0.ts +10 -3
- package/utility/durableFile.ts +96 -0
- package/utility/errors/hdbError.ts +9 -0
- package/utility/hdbTerms.ts +1 -0
- package/validation/configValidator.ts +31 -0
- package/studio/web/assets/index-72cEH474.css +0 -1
|
@@ -34,6 +34,7 @@ const trackedTxns = new Set<DatabaseTransaction>();
|
|
|
34
34
|
const readTransactionOwners = new WeakMap<ReadTransaction, DatabaseTransaction>();
|
|
35
35
|
// Read options for a rotated generation's native transactions; shared because they never vary.
|
|
36
36
|
const SNAPSHOT_FREE = Object.freeze({ disableSnapshot: true });
|
|
37
|
+
const ONCE = Object.freeze({ once: true });
|
|
37
38
|
// Logical transactions the monitor supervises for their WRITES, kept apart from trackedTxns because the
|
|
38
39
|
// two have different units and different consumers: trackedTxns is per-link, bounds a read snapshot, and
|
|
39
40
|
// is what the read-queue-depth metric counts, while this holds one entry per logical transaction — the
|
|
@@ -498,14 +499,32 @@ export function trackReadRange(transaction: ReadTransaction, createRange: () =>
|
|
|
498
499
|
return range;
|
|
499
500
|
}
|
|
500
501
|
|
|
502
|
+
export function requestAbortedError(): ServerError {
|
|
503
|
+
// 499 (client closed request) rather than a retryable 503/408: no client is left to retry.
|
|
504
|
+
return new ServerError('Transaction was aborted because the client disconnected', 499);
|
|
505
|
+
}
|
|
506
|
+
|
|
501
507
|
type MaybePromise<T> = T | Promise<T>;
|
|
502
508
|
|
|
509
|
+
/**
|
|
510
|
+
* A search result set holding a read reference on a transaction. `onDone` returns that reference and
|
|
511
|
+
* clears itself, so calling it more than once is safe.
|
|
512
|
+
*/
|
|
513
|
+
export type OwnedReadIterator = { onDone?: (() => void) | null };
|
|
514
|
+
|
|
503
515
|
export type CommitOptions = {
|
|
504
516
|
doneWriting?: boolean;
|
|
505
517
|
timestamp?: number;
|
|
506
518
|
retries?: number;
|
|
507
519
|
flush?: boolean;
|
|
508
520
|
transaction?: RocksTransaction;
|
|
521
|
+
/**
|
|
522
|
+
* Internal: this call continues a commit attempt already under way on this chain (the extra-writes
|
|
523
|
+
* recursion, or the head's cascade into `next`) rather than starting a new one. It is what lets a
|
|
524
|
+
* poison that landed mid-attempt spare the rest of that attempt without also sparing a genuinely
|
|
525
|
+
* fresh commit issued while it is still running.
|
|
526
|
+
*/
|
|
527
|
+
continuation?: boolean;
|
|
509
528
|
};
|
|
510
529
|
|
|
511
530
|
type ReadTransaction = (LMDBTransaction | RocksTransaction) & {
|
|
@@ -595,6 +614,7 @@ export type TransactionWrite = {
|
|
|
595
614
|
// the commit derives stored state (folds, index diffs, residency) from its base entry, so
|
|
596
615
|
// save() must reload that base through the committing transaction's snapshot
|
|
597
616
|
reloadCommitBase?: boolean;
|
|
617
|
+
baseReadTxn?: any;
|
|
598
618
|
writeGeneration?: WriteGeneration;
|
|
599
619
|
instanceClosed?: boolean;
|
|
600
620
|
};
|
|
@@ -650,6 +670,12 @@ export function writeKeyId(key: Id): unknown {
|
|
|
650
670
|
return toBufferKey(key as any).toString('latin1');
|
|
651
671
|
}
|
|
652
672
|
|
|
673
|
+
function clearAttemptState(txn: DatabaseTransaction): void {
|
|
674
|
+
txn.poisonedMidCommit = false;
|
|
675
|
+
txn.postSubmitPoisoned = false;
|
|
676
|
+
txn.stalledCommitResourcesReleased = false;
|
|
677
|
+
}
|
|
678
|
+
|
|
653
679
|
type RocksTransactionWithRetry = RocksTransaction & { isRetry?: boolean };
|
|
654
680
|
|
|
655
681
|
export class DatabaseTransaction implements Transaction {
|
|
@@ -779,6 +805,56 @@ export class DatabaseTransaction implements Transaction {
|
|
|
779
805
|
if (committing) txn.commitPhaseTicks = 0;
|
|
780
806
|
}
|
|
781
807
|
}
|
|
808
|
+
// Set by abortDueToDisconnect when the request's client disconnects while this transaction is still
|
|
809
|
+
// open (harper#2001). Once poisoned, any further addWrite/commit throws requestAbortedError, mirroring
|
|
810
|
+
// timedOut above — the difference is only WHY the transaction was cut short.
|
|
811
|
+
declare disconnected?: boolean;
|
|
812
|
+
// The signal of the request this chain writes for, set on the root wherever a transaction is created
|
|
813
|
+
// for a request context (resources/transaction.ts, Table.ts txnForContext). Cancellation belongs to
|
|
814
|
+
// the request: once it aborts, no link admits another write, whichever transaction it arrives on.
|
|
815
|
+
declare requestSignal?: AbortSignal;
|
|
816
|
+
// On the root while the chain owns writes: attached at the first admitted write, released once they
|
|
817
|
+
// and every commit attempt have settled, so a read-only request never registers one.
|
|
818
|
+
declare requestAbortListener?: () => void;
|
|
819
|
+
// Read references taken through useReadTxn() and not yet returned: the ownership record that bounds a
|
|
820
|
+
// retained read snapshot. See closeOwnedReadIterators().
|
|
821
|
+
declare ownedReadIterators?: Set<OwnedReadIterator>;
|
|
822
|
+
// Depth of commit() attempts on this link that have not reached a native outcome.
|
|
823
|
+
commitsInFlight = 0;
|
|
824
|
+
// A poison landed while a commit was already in flight somewhere in the chain. Excuses the head's
|
|
825
|
+
// cascade into `next`, but not a link the monitor poisoned BEFORE any commit started.
|
|
826
|
+
declare poisonedMidCommit?: boolean;
|
|
827
|
+
// The bound exists because a commit that never settles would otherwise make the point-of-no-return
|
|
828
|
+
// warning permanent. A deadline rather than a tick count, and root-scoped: both engines' monitors
|
|
829
|
+
// can visit every link of a chain, so a counter would advance once per link per tick.
|
|
830
|
+
declare deferredPoisonDeadline?: number;
|
|
831
|
+
declare unsubmittedCommitDeadline?: number;
|
|
832
|
+
// Per-link submission state. A commit method can still be parked in pre-commit work, where an abort
|
|
833
|
+
// is safe; once storage work has been submitted, its outcome is unknown and monitor cleanup must not
|
|
834
|
+
// clear writes or blobs that the eventual commit can reference.
|
|
835
|
+
nativeCommitSubmitted = false;
|
|
836
|
+
// Root-only aggregate, retained until every attempt in the multi-store chain settles. An earlier
|
|
837
|
+
// link can have submitted before a later link parks in pre-commit work, so a per-link check alone
|
|
838
|
+
// cannot prove that aborting the logical transaction is safe.
|
|
839
|
+
commitSubmitted = false;
|
|
840
|
+
declare submittedLink?: DatabaseTransaction;
|
|
841
|
+
declare submittedLinks?: Set<DatabaseTransaction>;
|
|
842
|
+
// Keeps even a single-link submitted commit visible to the long-transaction monitor after its
|
|
843
|
+
// native handle has been detached from the read registry.
|
|
844
|
+
submittedCommitSupervised = false;
|
|
845
|
+
// A stalled post-submit attempt can outlive its request, but its separate read snapshot does not
|
|
846
|
+
// need to. This per-link latch makes that non-destructive cleanup once-only.
|
|
847
|
+
declare stalledCommitResourcesReleased?: boolean;
|
|
848
|
+
// Ordinary post-submit work is poisoned once after the diagnostic grace. The already-started chain
|
|
849
|
+
// continues; genuinely fresh work is rejected. Protected source/replay work is never poisoned.
|
|
850
|
+
declare postSubmitPoisoned?: boolean;
|
|
851
|
+
declare stalledCommitLogged?: boolean;
|
|
852
|
+
// Root-only: the owning scope ended while a commit was in flight, so the wrapper could neither abort
|
|
853
|
+
// it nor close the iterators it owns. Both are finished by endCommitAttempt() when the attempt settles,
|
|
854
|
+
// against the links captured here rather than the `next` chain a settling commit detaches.
|
|
855
|
+
declare scopeAbandoned?: boolean;
|
|
856
|
+
declare abandonedLinks?: DatabaseTransaction[];
|
|
857
|
+
committingWrites = false;
|
|
782
858
|
|
|
783
859
|
rangeReadActive = false;
|
|
784
860
|
|
|
@@ -831,6 +907,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
831
907
|
disableSnapshot: disableSnapshot || this.snapshotFree,
|
|
832
908
|
})
|
|
833
909
|
);
|
|
910
|
+
if (disableSnapshot || this.snapshotFree) (this.transaction as any).snapshotDisabled = true;
|
|
834
911
|
|
|
835
912
|
if (this.timestamp) {
|
|
836
913
|
this.transaction.setTimestamp(this.timestamp);
|
|
@@ -883,6 +960,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
883
960
|
for (let link: DatabaseTransaction = root; link; link = link.next) {
|
|
884
961
|
if (link.writeSupervised) return;
|
|
885
962
|
}
|
|
963
|
+
if (root.submittedCommitSupervised) return;
|
|
886
964
|
supervisedWriteRoots.delete(root);
|
|
887
965
|
}
|
|
888
966
|
|
|
@@ -898,6 +976,49 @@ export class DatabaseTransaction implements Transaction {
|
|
|
898
976
|
return transaction;
|
|
899
977
|
}
|
|
900
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Record that `iterator` owes a doneReadTxn(). Without the record, a retained handle's only
|
|
981
|
+
* reclamation path is an onDone() an abandoned request leaves nobody to call.
|
|
982
|
+
*/
|
|
983
|
+
registerReadIterator(iterator: OwnedReadIterator): void {
|
|
984
|
+
// A transaction with no handle of its own (an ImmediateTransaction, which a context reuses for
|
|
985
|
+
// every search it makes) has nothing to reclaim and is in no monitor's registry, so an entry here
|
|
986
|
+
// would never be swept — it would just pin every partially-consumed result set on that context.
|
|
987
|
+
if (!this.transaction && !this.readTxn) return;
|
|
988
|
+
(this.ownedReadIterators ??= new Set()).add(iterator);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
unregisterReadIterator(iterator: OwnedReadIterator): void {
|
|
992
|
+
this.ownedReadIterators?.delete(iterator);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Close every read iterator this transaction still owns, returning how many references came back.
|
|
997
|
+
* Idempotent — each onDone() clears itself — but only safe once nothing can still be consuming them.
|
|
998
|
+
*/
|
|
999
|
+
closeOwnedReadIterators(): number {
|
|
1000
|
+
let closed = 0;
|
|
1001
|
+
// The whole multi-store chain: a handler that searched a second database registered that
|
|
1002
|
+
// iterator on the `next` link that owns its handle, not on the head.
|
|
1003
|
+
for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
|
|
1004
|
+
const iterators = txn.ownedReadIterators;
|
|
1005
|
+
if (!iterators?.size) continue;
|
|
1006
|
+
// Snapshot first: onDone() deregisters, mutating the set as we walk it.
|
|
1007
|
+
for (const iterator of [...iterators]) {
|
|
1008
|
+
iterators.delete(iterator);
|
|
1009
|
+
try {
|
|
1010
|
+
if (iterator.onDone) {
|
|
1011
|
+
iterator.onDone();
|
|
1012
|
+
closed++;
|
|
1013
|
+
}
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
harperLogger.warn?.('Failed to close a read iterator owned by an abandoned transaction', error);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return closed;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
901
1022
|
useReadTxn(disableSnapshot?: boolean) {
|
|
902
1023
|
const readTxn = this.getReadTxn(disableSnapshot);
|
|
903
1024
|
// stackTraces is seeded by the same getReadTxn branch that registers the transaction with
|
|
@@ -933,6 +1054,16 @@ export class DatabaseTransaction implements Transaction {
|
|
|
933
1054
|
}
|
|
934
1055
|
}
|
|
935
1056
|
|
|
1057
|
+
private releaseRetainedWriteIntents(): void {
|
|
1058
|
+
if (this.writesAbandoned) return;
|
|
1059
|
+
this.writesAbandoned = true;
|
|
1060
|
+
try {
|
|
1061
|
+
(this.transaction as { abandonWrites?: () => void } | null)?.abandonWrites?.();
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
936
1067
|
/**
|
|
937
1068
|
* Force-release the retained native read handle without touching staged writes. Used by the
|
|
938
1069
|
* long-transaction monitor for a CLOSED (already-acknowledged) transaction whose iterators have
|
|
@@ -951,7 +1082,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
951
1082
|
* whether that happens naturally (doneReadTxn()) or is forced by the long-transaction monitor
|
|
952
1083
|
* (releaseReadTxn()).
|
|
953
1084
|
*/
|
|
954
|
-
|
|
1085
|
+
protected completeDeferredContextRelease(): void {
|
|
955
1086
|
if (!this.pendingContextRelease) return;
|
|
956
1087
|
this.pendingContextRelease = false;
|
|
957
1088
|
if (this.#context?.transaction === this) this.#context.transaction = RELEASED_TRANSACTION;
|
|
@@ -1122,7 +1253,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1122
1253
|
* must stay a plain assignment — `delete` repeatedly forces a long-lived, hot context into V8's
|
|
1123
1254
|
* dictionary-mode property storage.
|
|
1124
1255
|
*/
|
|
1125
|
-
|
|
1256
|
+
protected releaseContext(final: boolean): void {
|
|
1126
1257
|
if (!final) return;
|
|
1127
1258
|
if (this.readTxnsUsed > 0) {
|
|
1128
1259
|
this.pendingContextRelease = true;
|
|
@@ -1223,7 +1354,9 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1223
1354
|
}
|
|
1224
1355
|
|
|
1225
1356
|
addWrite(operation: TransactionWrite) {
|
|
1226
|
-
if (this.timedOut) throw transactionOpenTooLongError();
|
|
1357
|
+
if (this.timedOut || this.postSubmitPoisoned) throw transactionOpenTooLongError();
|
|
1358
|
+
if (this.disconnected) throw requestAbortedError();
|
|
1359
|
+
this.admitRequestWrite();
|
|
1227
1360
|
// A write is activity: it re-arms the idle limit on this link even though the reads it
|
|
1228
1361
|
// performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive
|
|
1229
1362
|
// and only an idle one holding write intents is reaped.
|
|
@@ -1263,6 +1396,16 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1263
1396
|
}
|
|
1264
1397
|
|
|
1265
1398
|
save(operation: TransactionWrite, transaction?: RocksTransaction, reloadEntry = false, options?: CommitOptions) {
|
|
1399
|
+
if (!transaction && !options) {
|
|
1400
|
+
if (this.timedOut || this.postSubmitPoisoned) throw transactionOpenTooLongError();
|
|
1401
|
+
if (this.disconnected) throw requestAbortedError();
|
|
1402
|
+
// A deferred save is staged by addWrite() but admitted again when it is finally saved.
|
|
1403
|
+
this.admitRequestWrite();
|
|
1404
|
+
}
|
|
1405
|
+
if (!transaction && this.open !== TRANSACTION_STATE.OPEN) {
|
|
1406
|
+
if (this.timedOut || this.postSubmitPoisoned) throw transactionOpenTooLongError();
|
|
1407
|
+
if (this.disconnected) throw requestAbortedError();
|
|
1408
|
+
}
|
|
1266
1409
|
const lockHandle = operation.lockHandle;
|
|
1267
1410
|
// Guard: a write staged through an expired or released lock handle must not land.
|
|
1268
1411
|
// The handle's lease timer already unlocked the native key; another holder may have taken it.
|
|
@@ -1366,10 +1509,22 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1366
1509
|
// against; a snapshot-free transaction (this.snapshotFree, after a mid-scope-commit rotation)
|
|
1367
1510
|
// has no snapshot for rocksdb-js to validate the Put against, so it only narrows the window to
|
|
1368
1511
|
// the read-to-put span rather than closing it (open follow-up, tracked in the PR description).
|
|
1369
|
-
//
|
|
1512
|
+
// Replay bypasses the cache: WeakRef targets survive the entire synchronous job, even after eviction.
|
|
1370
1513
|
const reloadsCommitBase = operation.reloadCommitBase && !operation.saved && !this.isReplay;
|
|
1371
|
-
|
|
1372
|
-
|
|
1514
|
+
// An entry read uncached through this same pinned-snapshot handle is already that base, until a retry resets
|
|
1515
|
+
// the snapshot or an earlier staged write to the key changes what a read returns; the resource withholds the
|
|
1516
|
+
// handle once its entry was replaced or evicted, and locked, snapshot-free and replayed writes always reload.
|
|
1517
|
+
const reusesBaseRead =
|
|
1518
|
+
operation.baseReadTxn === transaction &&
|
|
1519
|
+
this.retries === 0 &&
|
|
1520
|
+
!reloadEntry &&
|
|
1521
|
+
!operation.priorWrite &&
|
|
1522
|
+
!operation.lockHandle &&
|
|
1523
|
+
!this.snapshotFree &&
|
|
1524
|
+
!(transaction as any).snapshotDisabled &&
|
|
1525
|
+
!this.isReplay;
|
|
1526
|
+
if (!reusesBaseRead && (reloadEntry || operation.entry === undefined || reloadsCommitBase)) {
|
|
1527
|
+
const uncachedRead = !!operation.reloadCommitBase || reloadEntry || this.isReplay;
|
|
1373
1528
|
operation.entry = operation.store.getEntry(operation.key, { transaction, uncachedRead });
|
|
1374
1529
|
}
|
|
1375
1530
|
if (!operation.saved) {
|
|
@@ -1419,10 +1574,40 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1419
1574
|
}
|
|
1420
1575
|
|
|
1421
1576
|
/**
|
|
1422
|
-
*
|
|
1577
|
+
* Whether any link of this multi-store chain has a commit attempt that has not reached a native
|
|
1578
|
+
* outcome. Asked of the whole chain, not just this link: the head commits its own writes and then
|
|
1579
|
+
* cascades into `next`, so that cascade is a continuation of one logical commit, not a fresh one.
|
|
1580
|
+
* Walked from here as well when a settling commit has already detached this link from that chain.
|
|
1581
|
+
*/
|
|
1582
|
+
isChainCommitting(): boolean {
|
|
1583
|
+
let attached = false;
|
|
1584
|
+
const root = this.root ?? this;
|
|
1585
|
+
for (let txn: DatabaseTransaction = root; txn; txn = txn.next) {
|
|
1586
|
+
if (txn.commitsInFlight) return true;
|
|
1587
|
+
if (txn === this) attached = true;
|
|
1588
|
+
}
|
|
1589
|
+
if (submittedCommitStillInFlight(root)) return true;
|
|
1590
|
+
if (attached) return false;
|
|
1591
|
+
// Only a link a settling commit already detached needs its own walk, and its own attempt is
|
|
1592
|
+
// exactly the one that must not be torn out from under it. The ordinary case — asked of the root,
|
|
1593
|
+
// or of a link still on the chain — is answered by the single walk above.
|
|
1594
|
+
for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
|
|
1595
|
+
if (txn.commitsInFlight) return true;
|
|
1596
|
+
}
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* A submitted native write is this transaction's point of no destructive return. Only a FRESH
|
|
1602
|
+
* attempt — none already in flight, and no `options.transaction` from an immediate-commit re-entry —
|
|
1603
|
+
* is rejected by the poison flags. Everything that continues an already-submitted attempt (a
|
|
1604
|
+
* RETRY_NOW/ERR_BUSY/ERR_TRY_AGAIN recursion, the extra-writes recursion inside performCommit) must
|
|
1605
|
+
* reach its native outcome instead of abandoning unknown state; a poison that lands before submission
|
|
1606
|
+
* can still abort safely (see abortAndPoison).
|
|
1607
|
+
*
|
|
1608
|
+
* Resolves with information on the timestamp and success of the commit.
|
|
1423
1609
|
*/
|
|
1424
1610
|
commit(options: CommitOptions = {}): MaybePromise<CommitResolution> {
|
|
1425
|
-
if (this.timedOut) throw transactionOpenTooLongError();
|
|
1426
1611
|
if (this.monitorCommit && !options.transaction) {
|
|
1427
1612
|
return this.monitorCommit.then(
|
|
1428
1613
|
() => this.commit(options),
|
|
@@ -1436,6 +1621,225 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1436
1621
|
}
|
|
1437
1622
|
);
|
|
1438
1623
|
}
|
|
1624
|
+
if (!(options.transaction && this.commitsInFlight) && !(options.continuation && this.poisonedMidCommit)) {
|
|
1625
|
+
if (this.timedOut || this.postSubmitPoisoned) throw transactionOpenTooLongError();
|
|
1626
|
+
if (this.disconnected) throw requestAbortedError();
|
|
1627
|
+
}
|
|
1628
|
+
this.commitsInFlight = (this.commitsInFlight ?? 0) + 1;
|
|
1629
|
+
// Only a top-level attempt records the intent: a continuation would re-read hasPendingWrites()
|
|
1630
|
+
// after clearWrites() has already emptied it.
|
|
1631
|
+
if (this.commitsInFlight === 1 && !options.continuation && this.hasPendingWrites()) {
|
|
1632
|
+
(this.root ?? this).committingWrites = true;
|
|
1633
|
+
}
|
|
1634
|
+
let resolution: MaybePromise<CommitResolution>;
|
|
1635
|
+
try {
|
|
1636
|
+
resolution = this.performCommit(options);
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
this.endCommitAttempt();
|
|
1639
|
+
throw error;
|
|
1640
|
+
}
|
|
1641
|
+
if (!(resolution as Promise<CommitResolution>)?.then) {
|
|
1642
|
+
this.endCommitAttempt();
|
|
1643
|
+
return resolution;
|
|
1644
|
+
}
|
|
1645
|
+
return (resolution as Promise<CommitResolution>).then(
|
|
1646
|
+
(settled) => {
|
|
1647
|
+
this.endCommitAttempt();
|
|
1648
|
+
return settled;
|
|
1649
|
+
},
|
|
1650
|
+
(error) => {
|
|
1651
|
+
this.endCommitAttempt();
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
protected markNativeCommitSubmitted(): void {
|
|
1658
|
+
this.nativeCommitSubmitted = true;
|
|
1659
|
+
const root = this.root ?? this;
|
|
1660
|
+
root.commitSubmitted = true;
|
|
1661
|
+
if (!root.submittedLink) root.submittedLink = this;
|
|
1662
|
+
else if (root.submittedLink !== this) (root.submittedLinks ??= new Set()).add(this);
|
|
1663
|
+
// LMDB has its own monitor registry and overrides this method to enroll there. Do not put an
|
|
1664
|
+
// LMDB link into the RocksDB monitor as well (stagesWriteOnSave is the engine discriminator).
|
|
1665
|
+
if (this.stagesWriteOnSave) {
|
|
1666
|
+
root.submittedCommitSupervised = true;
|
|
1667
|
+
supervisedWriteRoots.add(root);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** Engine hook for a submission that has reached a native outcome. */
|
|
1672
|
+
protected nativeCommitAttemptEnded(): void {}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* True when a commit attempt is parked on this link that can no longer land: it never submitted, and
|
|
1676
|
+
* the link is poisoned, so performCommit()'s post-await poison check throws before it commits. The
|
|
1677
|
+
* monitor's commit-phase grace exists to let real pre-commit work finish; extending it to an attempt
|
|
1678
|
+
* with no outcome left only pins this link's read snapshot for the rest of the grace.
|
|
1679
|
+
*/
|
|
1680
|
+
commitAttemptDoomedByPoison(): boolean {
|
|
1681
|
+
return !this.nativeCommitSubmitted && !this.poisonedMidCommit && Boolean(this.timedOut || this.disconnected);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Admission for a new write on behalf of a request. A request already cancelled poisons the chain
|
|
1686
|
+
* (releasing whatever it staged) and is refused; otherwise the chain subscribes to the cancellation
|
|
1687
|
+
* for as long as it owns writes, so a disconnect releases its intents at once instead of when its
|
|
1688
|
+
* scope or pre-commit work happens to finish. Reads never subscribe.
|
|
1689
|
+
*/
|
|
1690
|
+
admitRequestWrite(): void {
|
|
1691
|
+
const root = this.root ?? this;
|
|
1692
|
+
const signal = root.requestSignal;
|
|
1693
|
+
if (!signal) return;
|
|
1694
|
+
if (signal.aborted) {
|
|
1695
|
+
this.abortDueToDisconnect();
|
|
1696
|
+
throw requestAbortedError();
|
|
1697
|
+
}
|
|
1698
|
+
if (root.requestAbortListener) return;
|
|
1699
|
+
const listener = () => {
|
|
1700
|
+
root.requestAbortListener = undefined;
|
|
1701
|
+
try {
|
|
1702
|
+
// isCommittingWrites() is not redundant: commit() marks itself CLOSED and clears its staged
|
|
1703
|
+
// writes before the native commit settles, so for that window the chain reads as idle.
|
|
1704
|
+
if ((root.open === TRANSACTION_STATE.OPEN && root.hasPendingWrites()) || root.isCommittingWrites())
|
|
1705
|
+
root.abortDueToDisconnect();
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
harperLogger.debug?.('aborting transaction on client disconnect', error);
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
root.requestAbortListener = listener;
|
|
1711
|
+
signal.addEventListener('abort', listener, ONCE);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
protected releaseRequestAbortListener(): void {
|
|
1715
|
+
const listener = this.requestAbortListener;
|
|
1716
|
+
if (!listener) return;
|
|
1717
|
+
this.requestAbortListener = undefined;
|
|
1718
|
+
this.requestSignal.removeEventListener('abort', listener);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Protected work has no proven resume path, so the monitor may observe but never poison it. */
|
|
1722
|
+
isProtectedCommit(): boolean {
|
|
1723
|
+
const root = this.root ?? this;
|
|
1724
|
+
let attached = false;
|
|
1725
|
+
for (let txn: DatabaseTransaction = root; txn; txn = txn.next) {
|
|
1726
|
+
if (txn.sourceApply || txn.isReplay) return true;
|
|
1727
|
+
if (txn === this) attached = true;
|
|
1728
|
+
}
|
|
1729
|
+
return !attached && Boolean(this.sourceApply || this.isReplay);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Reclaim only read resources that are separate from already-submitted writes. Never run this on a
|
|
1734
|
+
* pre-submit link: its native read handle can still carry the write batch the commit needs.
|
|
1735
|
+
*/
|
|
1736
|
+
releaseStalledCommitReadResources(): void {
|
|
1737
|
+
const release = (txn: DatabaseTransaction) => {
|
|
1738
|
+
if (!txn.nativeCommitSubmitted || txn.stalledCommitResourcesReleased) return;
|
|
1739
|
+
txn.stalledCommitResourcesReleased = true;
|
|
1740
|
+
if (txn.closeOwnedReadIterators() > 0)
|
|
1741
|
+
harperLogger.warn?.(
|
|
1742
|
+
`Read iterators held a submitted transaction's snapshot after its native commit stalled; releasing it, from table: ${
|
|
1743
|
+
(txn.db as any)?.name
|
|
1744
|
+
}`
|
|
1745
|
+
);
|
|
1746
|
+
if (txn.transaction || txn.readTxn) txn.releaseReadTxn();
|
|
1747
|
+
};
|
|
1748
|
+
const root = this.root ?? this;
|
|
1749
|
+
let attached = false;
|
|
1750
|
+
for (let txn: DatabaseTransaction = root; txn; txn = txn.next) {
|
|
1751
|
+
release(txn);
|
|
1752
|
+
if (txn === this) attached = true;
|
|
1753
|
+
}
|
|
1754
|
+
if (!attached) release(this);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private endCommitAttempt(): void {
|
|
1758
|
+
this.commitsInFlight--;
|
|
1759
|
+
if (!this.commitsInFlight) {
|
|
1760
|
+
this.nativeCommitSubmitted = false;
|
|
1761
|
+
this.nativeCommitAttemptEnded();
|
|
1762
|
+
}
|
|
1763
|
+
// A link closes its OWN iterators the moment its own attempt settles, without waiting for the
|
|
1764
|
+
// chain: LMDBTransaction's commit detaches `next` before awaiting the child, so a scope abandoned
|
|
1765
|
+
// in that window never captured this link and the chain-wide cleanup below cannot reach it. Safe
|
|
1766
|
+
// here and not earlier because this link's attempt is the one that just reached its outcome.
|
|
1767
|
+
if ((this.root ?? this).scopeAbandoned) this.closeOwnedReadIterators();
|
|
1768
|
+
if (this.isChainCommitting()) return;
|
|
1769
|
+
// The window has closed: a commit entered from here on is a fresh one, and the flags that landed
|
|
1770
|
+
// during the finished attempt must reject it like any other poisoned commit.
|
|
1771
|
+
const root = this.root ?? this;
|
|
1772
|
+
root.committingWrites = false;
|
|
1773
|
+
root.commitSubmitted = false;
|
|
1774
|
+
root.submittedCommitSupervised = false;
|
|
1775
|
+
root.deferredPoisonDeadline = undefined;
|
|
1776
|
+
root.unsubmittedCommitDeadline = undefined;
|
|
1777
|
+
root.stalledCommitLogged = false;
|
|
1778
|
+
const submittedLink = root.submittedLink;
|
|
1779
|
+
root.submittedLink = undefined;
|
|
1780
|
+
const submittedLinks = root.submittedLinks;
|
|
1781
|
+
root.submittedLinks = undefined;
|
|
1782
|
+
if (submittedLink) clearAttemptState(submittedLink);
|
|
1783
|
+
if (submittedLinks) for (const txn of submittedLinks) clearAttemptState(txn);
|
|
1784
|
+
let stillWriteSupervised = false;
|
|
1785
|
+
for (let txn: DatabaseTransaction = root; txn; txn = txn.next) {
|
|
1786
|
+
clearAttemptState(txn);
|
|
1787
|
+
if (txn.writeSupervised) stillWriteSupervised = true;
|
|
1788
|
+
}
|
|
1789
|
+
if (!stillWriteSupervised) supervisedWriteRoots.delete(root);
|
|
1790
|
+
if (!root.hasPendingWrites()) root.releaseRequestAbortListener();
|
|
1791
|
+
if (root.scopeAbandoned) {
|
|
1792
|
+
root.scopeAbandoned = false;
|
|
1793
|
+
// The links captured when the scope was abandoned, not the current `next` chain: a successful
|
|
1794
|
+
// multi-store commit clears `next` before this runs, which would otherwise leave a former
|
|
1795
|
+
// child's iterators owning a snapshot nothing can reach.
|
|
1796
|
+
const abandoned = root.abandonedLinks;
|
|
1797
|
+
root.abandonedLinks = undefined;
|
|
1798
|
+
if (abandoned) for (const link of abandoned) link.closeOwnedReadIterators();
|
|
1799
|
+
else root.closeOwnedReadIterators();
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* The scope that owned this transaction ended while a commit attempt was still in flight, so the
|
|
1805
|
+
* wrapper could not abort it. Surrender scope ownership now, or the attempt's own
|
|
1806
|
+
* rotateAfterMidScopeCommit reopens an instance with no wrapper left to commit or abort it and the
|
|
1807
|
+
* next transaction() on that context joins it and never commits. Iterator cleanup is deferred to
|
|
1808
|
+
* endCommitAttempt(), since closing them mid-attempt can pull the handle the commit is still
|
|
1809
|
+
* staging through.
|
|
1810
|
+
*/
|
|
1811
|
+
abandonScope(): void {
|
|
1812
|
+
const root = this.root ?? this;
|
|
1813
|
+
root.scopeAbandoned = true;
|
|
1814
|
+
const links: DatabaseTransaction[] = [];
|
|
1815
|
+
for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
|
|
1816
|
+
links.push(txn);
|
|
1817
|
+
txn.endScopeOwnership();
|
|
1818
|
+
// Ending ownership alone only stops the attempt's own rotation; until it reaches its native
|
|
1819
|
+
// outcome the instance is still OPEN, and a hung `before` hook makes that window arbitrarily
|
|
1820
|
+
// long. Submitted attempts are monitor-deferred; a pre-submit attempt remains safely abortable.
|
|
1821
|
+
// Not on its own sufficient:
|
|
1822
|
+
// LMDBTransaction's own commit reassigns `open`, so the release below is what actually keeps
|
|
1823
|
+
// the next write on this context off an instance with no wrapper behind it.
|
|
1824
|
+
txn.open = TRANSACTION_STATE.CLOSED;
|
|
1825
|
+
}
|
|
1826
|
+
root.abandonedLinks = links;
|
|
1827
|
+
// By abort()'s own rule, so a poisoned chain stays joined and throws while an ordinary one lets
|
|
1828
|
+
// the next write start fresh.
|
|
1829
|
+
this.releaseContext(!this.timedOut && !this.disconnected);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Whether this chain has a write-bearing commit attempt in flight. `hasPendingWrites()` cannot
|
|
1834
|
+
* answer that: commit() marks itself CLOSED and clears its staged writes well before the native
|
|
1835
|
+
* commit settles, so between those points a write-bearing transaction looks read-only and idle to
|
|
1836
|
+
* anything reading its public state — including transaction.ts's disconnect listener.
|
|
1837
|
+
*/
|
|
1838
|
+
isCommittingWrites(): boolean {
|
|
1839
|
+
return Boolean((this.root ?? this).committingWrites) && this.isChainCommitting();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
protected performCommit(options: CommitOptions = {}): MaybePromise<CommitResolution> {
|
|
1439
1843
|
// reused across retries — the native layer resets it in place (fresh snapshot) on IsBusy/TryAgain —
|
|
1440
1844
|
// but reassigned to a fresh replay transaction when outstanding read iterators retain this.transaction
|
|
1441
1845
|
let transaction = options.transaction ?? this.transaction;
|
|
@@ -1460,21 +1864,24 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1460
1864
|
if (completions.length > 0) {
|
|
1461
1865
|
this.setCommitPhase(true);
|
|
1462
1866
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
() => {
|
|
1867
|
+
let commitResult: MaybePromise<CommitResolution>;
|
|
1868
|
+
try {
|
|
1869
|
+
commitResult = when(completions.length > 0 ? Promise.all(completions) : null, () => {
|
|
1466
1870
|
if (completions.length > 0) this.setCommitPhase(false);
|
|
1467
1871
|
// The transaction can be aborted underneath us while we are parked in the await above — by the
|
|
1468
1872
|
// monitor once the commit phase outlives its grace, or through the multi-store poison chain.
|
|
1469
1873
|
// abort() cleared the write set and released the handle, so resuming would commit nothing and
|
|
1470
1874
|
// resolve as SUCCESS: the caller is told its write landed when it was dropped, and a write
|
|
1471
1875
|
// carrying a blob is left holding an instance whose file was unlinked (issue #2062).
|
|
1472
|
-
if (this.
|
|
1876
|
+
if (!this.poisonedMidCommit) {
|
|
1877
|
+
if (this.timedOut) throw transactionOpenTooLongError();
|
|
1878
|
+
if (this.disconnected) throw requestAbortedError();
|
|
1879
|
+
}
|
|
1473
1880
|
if (stagedWrites > 0 && this.writes.length === 0 && this.open === TRANSACTION_STATE.CLOSED)
|
|
1474
1881
|
throw new ServerError('Transaction was aborted while its commit was waiting on pre-commit work', 500);
|
|
1475
1882
|
if (this.writes.length > this.validated) {
|
|
1476
1883
|
// check just in case we got any more transactions while we were waiting, if so just recursively continue to finish the additional writes now
|
|
1477
|
-
return this.commit(options);
|
|
1884
|
+
return this.commit({ ...options, continuation: true });
|
|
1478
1885
|
}
|
|
1479
1886
|
// The save loop above can be what opened this transaction's native handle — save() attaches
|
|
1480
1887
|
// one when it had none, which is every ImmediateTransaction commit since its getReadTxn
|
|
@@ -1545,6 +1952,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1545
1952
|
// with options.transaction set this is a retry round — the save loop above already
|
|
1546
1953
|
// re-staged the writes into it
|
|
1547
1954
|
this.assertDatabaseCommitAllowed(transaction);
|
|
1955
|
+
this.markNativeCommitSubmitted();
|
|
1548
1956
|
commitResolution = transaction.commit() as Promise<void>;
|
|
1549
1957
|
recordCommitLatency(commitResolution, performance.now());
|
|
1550
1958
|
// Write-queue-depth accounting for this replay commit happens uniformly below, via
|
|
@@ -1562,14 +1970,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1562
1970
|
// post-submit steps here: the replay commit is already in flight, so a throw must
|
|
1563
1971
|
// not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7
|
|
1564
1972
|
// lacks the method.
|
|
1565
|
-
|
|
1566
|
-
this.writesAbandoned = true;
|
|
1567
|
-
try {
|
|
1568
|
-
(this.transaction as { abandonWrites?: () => void } | null)?.abandonWrites?.();
|
|
1569
|
-
} catch (error) {
|
|
1570
|
-
harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1973
|
+
this.releaseRetainedWriteIntents();
|
|
1573
1974
|
} else {
|
|
1574
1975
|
// no more reads need to be performed, just commit/abort based if there are any writes
|
|
1575
1976
|
this.detachOwnedTransaction(); // any further operations operate immediately
|
|
@@ -1605,6 +2006,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1605
2006
|
// sentinel (a number) is why commitResolution is typed
|
|
1606
2007
|
// Promise<number | void>; it is handled in the resolve callback below.
|
|
1607
2008
|
this.assertDatabaseCommitAllowed(transaction);
|
|
2009
|
+
this.markNativeCommitSubmitted();
|
|
1608
2010
|
commitResolution = transaction.commit();
|
|
1609
2011
|
// Record how long this commit stays outstanding (submit → settle) as a distribution
|
|
1610
2012
|
// metric. This is the same clock the overload check uses (trackOutstandingCommit
|
|
@@ -1687,7 +2089,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1687
2089
|
// the next store — it must commit its own writes through its own transaction
|
|
1688
2090
|
let nextCommit: MaybePromise<CommitResolution>;
|
|
1689
2091
|
try {
|
|
1690
|
-
nextCommit = this.next.commit(
|
|
2092
|
+
nextCommit = this.next.commit({ ...options, transaction: undefined, continuation: true });
|
|
1691
2093
|
} catch (error) {
|
|
1692
2094
|
// This store has landed; its bookkeeping below must run before the failure surfaces.
|
|
1693
2095
|
nextCommit = Promise.reject(error);
|
|
@@ -1696,14 +2098,15 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1696
2098
|
completions.push(nextCommit);
|
|
1697
2099
|
}
|
|
1698
2100
|
if (options?.flush) {
|
|
1699
|
-
|
|
2101
|
+
const store = this.writes[0]?.store;
|
|
2102
|
+
if (store) completions.push(store.flushed);
|
|
1700
2103
|
}
|
|
1701
2104
|
if (this.replicatedConfirmation) {
|
|
1702
2105
|
// if we want to wait for replication confirmation, we need to track the transaction times
|
|
1703
2106
|
// and when replication notifications come in, we count the number of confirms until we reach the desired number
|
|
1704
|
-
const databaseName = this.writes[0]
|
|
2107
|
+
const databaseName = this.writes[0]?.store.rootStore.databaseName;
|
|
1705
2108
|
const lastEntry = this.lastConfirmableEntry();
|
|
1706
|
-
if (confirmReplication && lastEntry) {
|
|
2109
|
+
if (confirmReplication && databaseName && lastEntry) {
|
|
1707
2110
|
completions.push(
|
|
1708
2111
|
confirmReplication(databaseName, (lastEntry as any).version, this.replicatedConfirmation)
|
|
1709
2112
|
);
|
|
@@ -1822,9 +2225,8 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1822
2225
|
harperLogger.debug?.('aborting transaction after failed commit', abortError);
|
|
1823
2226
|
}
|
|
1824
2227
|
// A terminal failure is just as final as a success — release the context's
|
|
1825
|
-
// back-reference
|
|
1826
|
-
// rejection handler
|
|
1827
|
-
// CLOSED wrapper forever.
|
|
2228
|
+
// back-reference while handling the native transaction. performCommit's outer
|
|
2229
|
+
// rejection handler then aborts the wrapper and its linked chain.
|
|
1828
2230
|
// A failed commit must never be followed by a resumed segment: this generation is
|
|
1829
2231
|
// finished and its durability is unknown, so ownership goes with it.
|
|
1830
2232
|
this.endScopeOwnership();
|
|
@@ -1871,7 +2273,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1871
2273
|
// as above: the next store must not inherit this store's explicit native transaction
|
|
1872
2274
|
let nextResolution;
|
|
1873
2275
|
try {
|
|
1874
|
-
nextResolution = this.next?.commit(
|
|
2276
|
+
nextResolution = this.next?.commit({ ...options, transaction: undefined, continuation: true });
|
|
1875
2277
|
} catch (error) {
|
|
1876
2278
|
// A synchronous throw reaches neither rejection handler below, and the head has already
|
|
1877
2279
|
// committed — surrender ownership here too, or the scope stays resumable on top of a
|
|
@@ -1903,13 +2305,17 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1903
2305
|
}
|
|
1904
2306
|
this.completeMidScopeCommit(options);
|
|
1905
2307
|
return txnResolution;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
2308
|
+
});
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
this.setCommitPhase(false);
|
|
2311
|
+
this.abortAfterCommitError(error);
|
|
2312
|
+
}
|
|
2313
|
+
if ((commitResult as Promise<CommitResolution>)?.then)
|
|
2314
|
+
return (commitResult as Promise<CommitResolution>).catch((error) => {
|
|
1908
2315
|
this.setCommitPhase(false);
|
|
1909
|
-
this.
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
);
|
|
2316
|
+
this.abortAfterCommitError(error);
|
|
2317
|
+
});
|
|
2318
|
+
return commitResult;
|
|
1913
2319
|
}
|
|
1914
2320
|
/**
|
|
1915
2321
|
* A successful commit that is NOT the scope's final one leaves the scope still running and still
|
|
@@ -1931,7 +2337,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1931
2337
|
* commit path must run this, and none may do one half without the other.
|
|
1932
2338
|
*/
|
|
1933
2339
|
/** Both scope flags leave together, so no exit can clear one and keep the other. */
|
|
1934
|
-
|
|
2340
|
+
protected endScopeOwnership(): void {
|
|
1935
2341
|
this.#scopeOwned = false;
|
|
1936
2342
|
this.snapshotFree = false;
|
|
1937
2343
|
}
|
|
@@ -1943,7 +2349,15 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1943
2349
|
|
|
1944
2350
|
/** See completeMidScopeCommit, which is the only caller and carries the reasoning. */
|
|
1945
2351
|
private rotateAfterMidScopeCommit(options: CommitOptions): void {
|
|
1946
|
-
if (
|
|
2352
|
+
if (
|
|
2353
|
+
options.doneWriting ||
|
|
2354
|
+
this.timedOut ||
|
|
2355
|
+
this.disconnected ||
|
|
2356
|
+
this.postSubmitPoisoned ||
|
|
2357
|
+
this.transaction ||
|
|
2358
|
+
!this.#scopeOwned
|
|
2359
|
+
)
|
|
2360
|
+
return;
|
|
1947
2361
|
this.open = TRANSACTION_STATE.OPEN;
|
|
1948
2362
|
this.snapshotFree = true;
|
|
1949
2363
|
this.writesAbandoned = false;
|
|
@@ -1963,36 +2377,66 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1963
2377
|
throw new DatabaseClosingError(rootStore?.databaseName ?? rootStore?.path ?? 'unknown', retryable);
|
|
1964
2378
|
}
|
|
1965
2379
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2380
|
+
protected abortAfterCommitError(error): never {
|
|
2381
|
+
try {
|
|
2382
|
+
// Not "always retain": abort(true) keeps the native handle only while read iterators still
|
|
2383
|
+
// own it. resources/transaction.ts's own post-error abort passes the same argument, so the
|
|
2384
|
+
// two layers cannot contradict each other one frame apart.
|
|
2385
|
+
this.abort(true);
|
|
2386
|
+
} catch (abortError) {
|
|
2387
|
+
harperLogger.debug?.('aborting transaction after a failed commit', abortError);
|
|
2388
|
+
}
|
|
2389
|
+
throw error;
|
|
2390
|
+
}
|
|
2391
|
+
abort(retainReadTransaction = false, cascade = true): void {
|
|
2392
|
+
const next = cascade ? this.next : undefined;
|
|
2393
|
+
if (cascade && !retainReadTransaction) this.next = null;
|
|
1973
2394
|
try {
|
|
2395
|
+
const hasOpenReadIterator =
|
|
2396
|
+
retainReadTransaction &&
|
|
2397
|
+
this.transaction &&
|
|
2398
|
+
(this.readTxnsUsed > 1 || (this.baseReadRefConsumed && this.readTxnsUsed > 0));
|
|
2399
|
+
if (hasOpenReadIterator) {
|
|
2400
|
+
this.releaseRetainedWriteIntents();
|
|
2401
|
+
if (!this.baseReadRefConsumed) {
|
|
2402
|
+
this.doneReadTxn();
|
|
2403
|
+
this.baseReadRefConsumed = true;
|
|
2404
|
+
}
|
|
2405
|
+
} else {
|
|
2406
|
+
while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
|
|
2407
|
+
}
|
|
2408
|
+
// A write-only transaction never took a read reference (getReadTxn was never called), so the
|
|
2409
|
+
// loop above releases nothing even though save() created a native handle. Keep an iterator's
|
|
2410
|
+
// handle alive, but release every other remaining handle through the shared cleanup path.
|
|
2411
|
+
if (this.transaction && !hasOpenReadIterator) this.releaseReadTxn();
|
|
2412
|
+
this.open = TRANSACTION_STATE.CLOSED;
|
|
2413
|
+
this.timestamp = 0; // a lock stamp pinned for this write set must not leak into the next
|
|
2414
|
+
this.drainCompletions();
|
|
1974
2415
|
for (const write of this.writes) {
|
|
1975
2416
|
if (write?.savedBlobs)
|
|
1976
2417
|
cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
|
|
1977
2418
|
}
|
|
1978
2419
|
} finally {
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2420
|
+
try {
|
|
2421
|
+
this.open = TRANSACTION_STATE.CLOSED;
|
|
2422
|
+
this.endScopeOwnership(); // the scope is over; nothing may rotate this instance again
|
|
2423
|
+
this.clearWrites();
|
|
2424
|
+
this.releaseRecordLocks();
|
|
2425
|
+
// A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
|
|
2426
|
+
// Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
|
|
2427
|
+
// starting a fresh one) so the rest of the logical operation fails atomically via the
|
|
2428
|
+
// poison check in addWrite()/commit(), rather than silently landing a later write on a
|
|
2429
|
+
// brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
|
|
2430
|
+
// make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
|
|
2431
|
+
this.releaseContext(!this.timedOut && !this.disconnected);
|
|
2432
|
+
if (!this.root) this.releaseRequestAbortListener();
|
|
2433
|
+
} finally {
|
|
2434
|
+
if (next && !(next.nativeCommitSubmitted && next.commitsInFlight)) {
|
|
2435
|
+
try {
|
|
2436
|
+
next.abort(retainReadTransaction);
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
harperLogger.debug?.('cleaning up a chained transaction during abort', error);
|
|
2439
|
+
}
|
|
1996
2440
|
}
|
|
1997
2441
|
}
|
|
1998
2442
|
}
|
|
@@ -2106,32 +2550,95 @@ export class DatabaseTransaction implements Transaction {
|
|
|
2106
2550
|
*/
|
|
2107
2551
|
hasPendingWrites(): boolean {
|
|
2108
2552
|
for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
|
|
2109
|
-
|
|
2553
|
+
for (let i = 0; i < txn.writes.length; i++) {
|
|
2554
|
+
if (txn.writes[i]) return true;
|
|
2555
|
+
}
|
|
2110
2556
|
}
|
|
2111
2557
|
return false;
|
|
2112
2558
|
}
|
|
2113
2559
|
/**
|
|
2114
|
-
* Abort and poison this transaction
|
|
2115
|
-
* commit
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2560
|
+
* Abort and poison this transaction (and its multi-store `next` chain) for `reason`, throwing on any
|
|
2561
|
+
* further addWrite/commit (transactionOpenTooLongError / requestAbortedError) rather than the monitor
|
|
2562
|
+
* force-committing a partial write set on the application's behalf (issue #1407) or a chain link
|
|
2563
|
+
* silently committing on behalf of a request that was supposed to have been cut off (harper#2001).
|
|
2564
|
+
* Poisons every link first, then aborts each — so a throw from one link's abort() can't leave a later
|
|
2565
|
+
* link un-poisoned and eligible for a commit cascade to force-commit it or leak its native handle.
|
|
2566
|
+
*
|
|
2567
|
+
* A link whose native commit was submitted is past the point of no destructive return: poison it for
|
|
2568
|
+
* everything that comes after, but let that attempt reach its outcome rather than racing cleanup
|
|
2569
|
+
* against unknown durability. Accepted consequence: a submitted write can land after the client is gone.
|
|
2570
|
+
*
|
|
2571
|
+
* For a disconnect that point covers the whole chain: its unsubmitted links ride the cascade already
|
|
2572
|
+
* under way, so the logical commit lands whole instead of splitting across stores (#1407). The monitor
|
|
2573
|
+
* aborts them independently instead, because it only gets here for pre-commit work that outlived its grace.
|
|
2120
2574
|
*/
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2575
|
+
abortAndPoison(reason: 'timedOut' | 'disconnected'): void {
|
|
2576
|
+
const root = this.root ?? this;
|
|
2577
|
+
const submittedCommit = root.commitSubmitted && this.isChainCommitting();
|
|
2578
|
+
const spareWholeChain = submittedCommit && reason === 'disconnected';
|
|
2579
|
+
const links = new Set<DatabaseTransaction>();
|
|
2580
|
+
const collect = (start: DatabaseTransaction | undefined) => {
|
|
2581
|
+
for (let txn = start; txn; txn = txn.next) links.add(txn);
|
|
2582
|
+
};
|
|
2583
|
+
collect(root);
|
|
2584
|
+
collect(this);
|
|
2585
|
+
collect(root.submittedLink);
|
|
2586
|
+
if (root.submittedLinks) for (const txn of root.submittedLinks) collect(txn);
|
|
2587
|
+
for (const txn of links) {
|
|
2588
|
+
txn[reason] = true;
|
|
2589
|
+
if (spareWholeChain || (submittedCommit && txn.nativeCommitSubmitted)) txn.poisonedMidCommit = true;
|
|
2590
|
+
else {
|
|
2591
|
+
txn.poisonedMidCommit = false;
|
|
2592
|
+
txn.open = TRANSACTION_STATE.CLOSED;
|
|
2593
|
+
}
|
|
2127
2594
|
}
|
|
2128
|
-
|
|
2595
|
+
if (spareWholeChain) return;
|
|
2596
|
+
for (const txn of links) {
|
|
2597
|
+
if (submittedCommit && txn.nativeCommitSubmitted) continue;
|
|
2129
2598
|
try {
|
|
2130
|
-
|
|
2599
|
+
// An iterator's native cursor is owned by this transaction. Drop the staged writes now, but
|
|
2600
|
+
// leave its handle for doneReadTxn() so the iterator can finish without using a freed handle.
|
|
2601
|
+
// Abort only this link: a later link may already have submitted work whose outcome is unknown.
|
|
2602
|
+
txn.abort(true, false);
|
|
2131
2603
|
} catch (error) {
|
|
2132
|
-
harperLogger.debug?.(`Error aborting
|
|
2604
|
+
harperLogger.debug?.(`Error aborting ${reason} transaction in chain`, error);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
/** The long-transaction monitor calls this when a write-bearing transaction stays open too long. */
|
|
2609
|
+
abortDueToTimeout(): void {
|
|
2610
|
+
this.abortAndPoison('timedOut');
|
|
2611
|
+
}
|
|
2612
|
+
/** A request this chain writes for was cancelled (admitRequestWrite, harper#2001). */
|
|
2613
|
+
abortDueToDisconnect(): void {
|
|
2614
|
+
this.abortAndPoison('disconnected');
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* Poison fresh ordinary work after a submitted commit stalls, without aborting any unknown native
|
|
2619
|
+
* outcome. Start at the root so every still-attached link receives the poison; also cover a link an
|
|
2620
|
+
* LMDB commit detached before its child settled.
|
|
2621
|
+
*/
|
|
2622
|
+
poisonAfterStalledSubmittedCommit(): void {
|
|
2623
|
+
const root = this.root ?? this;
|
|
2624
|
+
const links = new Set<DatabaseTransaction>();
|
|
2625
|
+
const collect = (start: DatabaseTransaction | undefined) => {
|
|
2626
|
+
for (let txn = start; txn; txn = txn.next) links.add(txn);
|
|
2627
|
+
};
|
|
2628
|
+
collect(root);
|
|
2629
|
+
collect(this);
|
|
2630
|
+
collect(root.submittedLink);
|
|
2631
|
+
if (root.submittedLinks) for (const txn of root.submittedLinks) collect(txn);
|
|
2632
|
+
for (const txn of links) {
|
|
2633
|
+
txn.postSubmitPoisoned = true;
|
|
2634
|
+
// Not on a link an earlier poison already aborted: abort() cleared its write set, so excusing
|
|
2635
|
+
// its continuation from the poison check would let it resume onto nothing and report success.
|
|
2636
|
+
if (!txn.commitAttemptDoomedByPoison()) txn.poisonedMidCommit = true;
|
|
2637
|
+
if (txn.nativeCommitSubmitted) {
|
|
2638
|
+
txn.timedOut = true;
|
|
2133
2639
|
}
|
|
2134
2640
|
}
|
|
2641
|
+
root.timedOut = true;
|
|
2135
2642
|
}
|
|
2136
2643
|
directCommitSync(): void {
|
|
2137
2644
|
const transaction = this.transaction;
|
|
@@ -2198,6 +2705,7 @@ export class ImmediateTransaction extends DatabaseTransaction {
|
|
|
2198
2705
|
// and reload within it; with no handle super.save() would open its own and nest a commit().
|
|
2199
2706
|
super.save(operation, args[1], true);
|
|
2200
2707
|
} else {
|
|
2708
|
+
this.admitRequestWrite(); // a deferred save reaches here without passing through addWrite()
|
|
2201
2709
|
this.isCommitting = true;
|
|
2202
2710
|
// A synchronous throw from commit() (e.g. a 409 from an expired lock handle) would
|
|
2203
2711
|
// otherwise leave isCommitting latched at true, causing every subsequent save() in this
|
|
@@ -2396,6 +2904,102 @@ function reportIfLongLived(
|
|
|
2396
2904
|
}
|
|
2397
2905
|
}
|
|
2398
2906
|
|
|
2907
|
+
/** Diagnostic grace after a native submission outlives the ordinary transaction timeout. */
|
|
2908
|
+
const MAX_DEFERRED_POISON_TICKS = 2;
|
|
2909
|
+
const STALLED_COMMIT_LOG_MIN_INTERVAL_MS = 1000;
|
|
2910
|
+
let lastStalledCommitWarningAt = -Infinity;
|
|
2911
|
+
let lastStalledCommitErrorAt = -Infinity;
|
|
2912
|
+
|
|
2913
|
+
function submittedCommitStillInFlight(root: DatabaseTransaction): boolean {
|
|
2914
|
+
if (root.submittedLink?.nativeCommitSubmitted && root.submittedLink.commitsInFlight) return true;
|
|
2915
|
+
if (root.submittedLinks) {
|
|
2916
|
+
for (const txn of root.submittedLinks) {
|
|
2917
|
+
if (txn.nativeCommitSubmitted && txn.commitsInFlight) return true;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
return false;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* True when this tick must leave `txn` alone because some link has submitted writes whose native
|
|
2925
|
+
* outcome is not known yet. Merely entering commit() is not enough: a wholly pre-submit transaction
|
|
2926
|
+
* remains subject to shouldSpareCommitPhase() and its ordinary bounded grace.
|
|
2927
|
+
*/
|
|
2928
|
+
export function deferForCommitInFlight(
|
|
2929
|
+
txn: DatabaseTransaction,
|
|
2930
|
+
url: string | undefined,
|
|
2931
|
+
expiration = txnExpiration
|
|
2932
|
+
): boolean {
|
|
2933
|
+
const root = txn.root ?? txn;
|
|
2934
|
+
if (!txn.isChainCommitting() || !root.commitSubmitted) return false;
|
|
2935
|
+
// A sibling's submitted commit is protected by the links that own it. This one is poisoned and never
|
|
2936
|
+
// submitted, so deferring it only holds its retained snapshot for the diagnostic and commit-phase
|
|
2937
|
+
// graces on behalf of an attempt that is going to throw.
|
|
2938
|
+
if (txn.commitAttemptDoomedByPoison()) return false;
|
|
2939
|
+
// performance.now(), not Date.now(): a clock correction must not extend a stalled commit's hold on its
|
|
2940
|
+
// diagnostic grace, nor report a legitimately slow one early.
|
|
2941
|
+
const now = performance.now();
|
|
2942
|
+
const deadline = (root.deferredPoisonDeadline ??= now + MAX_DEFERRED_POISON_TICKS * expiration);
|
|
2943
|
+
let hasUnsubmittedCommit = false;
|
|
2944
|
+
let unsubmittedCommitGraceExpired = false;
|
|
2945
|
+
if (now >= deadline) {
|
|
2946
|
+
// Only reclaim after the commit has genuinely stalled. Submitted writes no longer depend on these
|
|
2947
|
+
// separate snapshots; pre-submit handles can still carry writes needed by the attempt.
|
|
2948
|
+
txn.releaseStalledCommitReadResources();
|
|
2949
|
+
if (!txn.isProtectedCommit()) {
|
|
2950
|
+
if (!root.postSubmitPoisoned) txn.poisonAfterStalledSubmittedCommit();
|
|
2951
|
+
const links = new Set<DatabaseTransaction>();
|
|
2952
|
+
const collect = (start: DatabaseTransaction | undefined) => {
|
|
2953
|
+
for (let link = start; link; link = link.next) links.add(link);
|
|
2954
|
+
};
|
|
2955
|
+
collect(root);
|
|
2956
|
+
collect(txn);
|
|
2957
|
+
collect(root.submittedLink);
|
|
2958
|
+
if (root.submittedLinks) for (const link of root.submittedLinks) collect(link);
|
|
2959
|
+
for (const link of links) {
|
|
2960
|
+
if (link.committing && !link.nativeCommitSubmitted) {
|
|
2961
|
+
hasUnsubmittedCommit = true;
|
|
2962
|
+
break;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (hasUnsubmittedCommit) {
|
|
2966
|
+
const unsubmittedDeadline = (root.unsubmittedCommitDeadline ??= now + COMMIT_PHASE_GRACE * expiration);
|
|
2967
|
+
if (now >= unsubmittedDeadline) {
|
|
2968
|
+
unsubmittedCommitGraceExpired = true;
|
|
2969
|
+
txn.abortDueToTimeout();
|
|
2970
|
+
}
|
|
2971
|
+
} else root.unsubmittedCommitDeadline = undefined;
|
|
2972
|
+
}
|
|
2973
|
+
if (
|
|
2974
|
+
!txn.nativeCommitSubmitted &&
|
|
2975
|
+
txn.open === TRANSACTION_STATE.CLOSED &&
|
|
2976
|
+
!(txn === root && submittedCommitStillInFlight(root))
|
|
2977
|
+
)
|
|
2978
|
+
return hasUnsubmittedCommit && !unsubmittedCommitGraceExpired;
|
|
2979
|
+
if (!root.stalledCommitLogged && now - lastStalledCommitErrorAt >= STALLED_COMMIT_LOG_MIN_INTERVAL_MS) {
|
|
2980
|
+
lastStalledCommitErrorAt = now;
|
|
2981
|
+
root.stalledCommitLogged = true;
|
|
2982
|
+
harperLogger.error(
|
|
2983
|
+
`A native commit has not settled after exceeding the open-transaction limit by a further ${
|
|
2984
|
+
MAX_DEFERRED_POISON_TICKS * expiration
|
|
2985
|
+
}ms. Its outcome is unknown, so Harper will not abort or delete its writes; fresh ordinary work ` +
|
|
2986
|
+
`on this transaction is rejected until it settles, and the worker may require a restart, from table: ${
|
|
2987
|
+
(txn.db as any)?.name + (url ? ' path: ' + url : '')
|
|
2988
|
+
}`
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
} else if (now - lastStalledCommitWarningAt >= STALLED_COMMIT_LOG_MIN_INTERVAL_MS) {
|
|
2992
|
+
lastStalledCommitWarningAt = now;
|
|
2993
|
+
harperLogger.warn?.(
|
|
2994
|
+
`Transaction exceeded the open-transaction limit after submitting a native commit; preserving its unknown outcome, from table: ${
|
|
2995
|
+
(txn.db as any)?.name + (url ? ' path: ' + url : '')
|
|
2996
|
+
}`
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
txn.timeout = Math.max(expiration, txn.timeoutBudget ?? 0);
|
|
3000
|
+
return true;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
2399
3003
|
function startMonitoringTxns() {
|
|
2400
3004
|
timer = setInterval(function () {
|
|
2401
3005
|
monitorTick++;
|
|
@@ -2434,6 +3038,20 @@ function startMonitoringTxns() {
|
|
|
2434
3038
|
if (txn.writeTimeout > 0) txn.writeTimeout -= txnExpiration;
|
|
2435
3039
|
if (txn.timeout <= 0) {
|
|
2436
3040
|
const url = (txn.getContext() as any)?.url;
|
|
3041
|
+
if (deferForCommitInFlight(txn, url, txnExpiration)) return;
|
|
3042
|
+
if (
|
|
3043
|
+
txn.open === TRANSACTION_STATE.CLOSED &&
|
|
3044
|
+
!txn.commitAttemptDoomedByPoison() &&
|
|
3045
|
+
shouldSpareCommitPhase(txn, checkedCommitPhaseChains)
|
|
3046
|
+
) {
|
|
3047
|
+
harperLogger.warn?.(
|
|
3048
|
+
`Transaction has been in its commit phase past the open-transaction limit, waiting on pre-commit work; letting it complete, from table: ${
|
|
3049
|
+
(txn.db as any)?.name + (url ? ' path: ' + url : '')
|
|
3050
|
+
}`
|
|
3051
|
+
);
|
|
3052
|
+
txn.timeout = Math.max(txnExpiration, txn.timeoutBudget ?? 0);
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
2437
3055
|
if (txn.open === TRANSACTION_STATE.CLOSED) {
|
|
2438
3056
|
if (!txn.transaction) {
|
|
2439
3057
|
// Nothing left to supervise, and this is the registry's only unconditional exit:
|
|
@@ -2443,18 +3061,21 @@ function startMonitoringTxns() {
|
|
|
2443
3061
|
txn.dropWriteSupervision();
|
|
2444
3062
|
return;
|
|
2445
3063
|
}
|
|
2446
|
-
//
|
|
2447
|
-
// replay commit (see the outstanding-iterators branch in commit()) and
|
|
2448
|
-
//
|
|
2449
|
-
//
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
3064
|
+
// Staged writes are not the monitor's to abort here: on the committed path they ride an
|
|
3065
|
+
// in-flight replay commit (see the outstanding-iterators branch in commit()) and dropping
|
|
3066
|
+
// them would re-introduce the silent write-loss-after-ack this structure prevents; on the
|
|
3067
|
+
// poisoned path abort(true) already discarded them. Only the snapshot's lifetime is left.
|
|
3068
|
+
// Close the owning iterators first so the handle comes back through doneReadTxn() — a
|
|
3069
|
+
// transaction poisoned outside transaction() has no callback settlement to do it.
|
|
3070
|
+
const closedIterators = txn.closeOwnedReadIterators();
|
|
2452
3071
|
harperLogger.warn?.(
|
|
2453
|
-
`Read iterators held a
|
|
2454
|
-
|
|
2455
|
-
}
|
|
3072
|
+
`Read iterators held a ${
|
|
3073
|
+
txn.timedOut || txn.disconnected ? 'poisoned' : 'committed'
|
|
3074
|
+
} transaction's snapshot past the open-transaction limit; ${
|
|
3075
|
+
txn.transaction ? 'releasing it' : `closed ${closedIterators} abandoned iterator(s)`
|
|
3076
|
+
}, from table: ${(txn.db as any)?.name + (url ? ' path: ' + url : '')}`
|
|
2456
3077
|
);
|
|
2457
|
-
txn.releaseReadTxn();
|
|
3078
|
+
if (txn.transaction) txn.releaseReadTxn();
|
|
2458
3079
|
} else if (shouldSpareCommitPhase(txn, checkedCommitPhaseChains)) {
|
|
2459
3080
|
// Parked in commit()'s pre-commit await — a `before`/`beforeIntermediate` hook, in practice a
|
|
2460
3081
|
// blob's durable file write, which for a multi-tens-of-MB payload legitimately outruns the
|