@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.
Files changed (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. 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
- private completeDeferredContextRelease(): void {
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
- private releaseContext(final: boolean): void {
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
- // Replays keep their pre-read base — their convergence contract is the replay pass itself.
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
- if (reloadEntry || operation.entry === undefined || reloadsCommitBase) {
1372
- const uncachedRead = (!!operation.reloadCommitBase && !this.isReplay) || reloadEntry;
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
- * Resolves with information on the timestamp and success of the commit
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
- return when(
1464
- completions.length > 0 ? Promise.all(completions) : null,
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.timedOut) throw transactionOpenTooLongError();
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
- if (!this.writesAbandoned) {
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(options.transaction ? { ...options, transaction: undefined } : options);
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
- completions.push(this.writes[0].store.flushed);
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].store.rootStore.databaseName;
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 here too, or transaction.ts's onComplete() (which has no
1826
- // rejection handler of its own) would leave a long-lived context pinning this
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(options.transaction ? { ...options, transaction: undefined } : options);
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
- (error) => {
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.abort();
1910
- throw error;
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
- private endScopeOwnership(): void {
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 (options.doneWriting || this.timedOut || this.transaction || !this.#scopeOwned) return;
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
- abort(): void {
1967
- while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
1968
- // Defensively release any native handle whose reference bookkeeping was already consumed.
1969
- if (this.transaction) this.releaseReadTxn();
1970
- this.open = TRANSACTION_STATE.CLOSED;
1971
- this.timestamp = 0; // a lock stamp pinned for this write set must not leak into the next
1972
- this.drainCompletions();
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
- this.endScopeOwnership(); // the scope is over; nothing may rotate this instance again
1980
- this.clearWrites();
1981
- this.releaseRecordLocks();
1982
- // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
1983
- // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
1984
- // starting a fresh one) so the rest of the logical operation fails atomically via the
1985
- // poison check in addWrite()/commit(), rather than silently landing a later write on a
1986
- // brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
1987
- // make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
1988
- this.releaseContext(!this.timedOut);
1989
- const next = this.next;
1990
- this.next = null;
1991
- if (next) {
1992
- try {
1993
- next.abort();
1994
- } catch (error) {
1995
- harperLogger.debug?.('cleaning up a chained transaction during abort', error);
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
- if (txn.writes.some((write) => write)) return true;
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 because it exceeded the open-transaction limit. The next write or
2115
- * commit throws transactionOpenTooLongError so the request fails cleanly and rolls back, rather than the
2116
- * monitor force-committing a partial write set on the application's behalf (issue #1407). The whole
2117
- * multi-store `next` chain is poisoned and aborted: writes to a second database live on `next`, so
2118
- * leaving it un-poisoned would let the head's commit cascade force-commit them (or orphan its resources
2119
- * until it self-times-out).
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
- abortDueToTimeout(): void {
2122
- // Poison every link first, then abort each, so a throw from one link's abort() can't leave later links
2123
- // in the chain un-poisoned (and thus eligible to be force-committed by a later commit cascade).
2124
- for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
2125
- txn.timedOut = true;
2126
- txn.open = TRANSACTION_STATE.CLOSED;
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
- for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
2595
+ if (spareWholeChain) return;
2596
+ for (const txn of links) {
2597
+ if (submittedCommit && txn.nativeCommitSubmitted) continue;
2129
2598
  try {
2130
- txn.abort();
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 timed-out transaction in chain: ${error.message}`);
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
- // The commit was already acknowledged; any staged writes are riding an in-flight
2447
- // replay commit (see the outstanding-iterators branch in commit()) and are not the
2448
- // monitor's to abort — dropping them here would re-introduce the silent
2449
- // write-loss-after-ack this branch structure exists to prevent. Only the read
2450
- // snapshot's lifetime is left to enforce: release the retained native handle that
2451
- // the overdue iterators are holding open.
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 committed transaction's snapshot past the open-transaction limit; releasing it, from table: ${
2454
- (txn.db as any)?.name + (url ? ' path: ' + url : '')
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