@harperfast/harper 5.2.0-beta.4 → 5.2.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 (320) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/copyDb.ts +21 -4
  4. package/bin/harper.ts +43 -55
  5. package/bin/help.ts +216 -0
  6. package/components/Application.ts +236 -46
  7. package/components/ApplicationScope.ts +26 -0
  8. package/components/EntryHandler.ts +410 -105
  9. package/components/RuntimeModuleTracker.ts +189 -0
  10. package/components/Scope.ts +120 -30
  11. package/components/componentLoader.ts +177 -25
  12. package/components/deployLifecycle.ts +119 -33
  13. package/components/mcp/toolRegistry.ts +10 -0
  14. package/components/mcp/tools/application.ts +12 -5
  15. package/components/mcp/tools/operations.ts +9 -0
  16. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  17. package/components/mcp/tools/schemas/operations.ts +9 -0
  18. package/components/operations.js +5 -6
  19. package/components/operationsValidation.js +32 -2
  20. package/components/scopeMount.ts +150 -0
  21. package/config/configUtils.ts +12 -9
  22. package/config-root.schema.json +14 -0
  23. package/dataLayer/backupManifest.ts +102 -0
  24. package/dataLayer/blobBackup.ts +286 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
  26. package/dataLayer/hdbInfoController.ts +8 -0
  27. package/dataLayer/restoreMarker.ts +276 -0
  28. package/dataLayer/rocksdbBackup.ts +1100 -0
  29. package/dataLayer/schemaDescribe.ts +2 -1
  30. package/dist/bin/backup.d.ts +9 -0
  31. package/dist/bin/backup.js +192 -0
  32. package/dist/bin/backup.js.map +1 -0
  33. package/dist/bin/cliOperations.d.ts +13 -0
  34. package/dist/bin/cliOperations.js +89 -70
  35. package/dist/bin/cliOperations.js.map +1 -1
  36. package/dist/bin/copyDb.js +13 -2
  37. package/dist/bin/copyDb.js.map +1 -1
  38. package/dist/bin/harper.d.ts +13 -0
  39. package/dist/bin/harper.js +45 -56
  40. package/dist/bin/harper.js.map +1 -1
  41. package/dist/bin/help.d.ts +8 -0
  42. package/dist/bin/help.js +192 -0
  43. package/dist/bin/help.js.map +1 -0
  44. package/dist/components/Application.d.ts +16 -1
  45. package/dist/components/Application.js +210 -38
  46. package/dist/components/Application.js.map +1 -1
  47. package/dist/components/ApplicationScope.d.ts +7 -0
  48. package/dist/components/ApplicationScope.js +22 -0
  49. package/dist/components/ApplicationScope.js.map +1 -1
  50. package/dist/components/EntryHandler.d.ts +4 -4
  51. package/dist/components/EntryHandler.js +386 -95
  52. package/dist/components/EntryHandler.js.map +1 -1
  53. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  54. package/dist/components/RuntimeModuleTracker.js +189 -0
  55. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  56. package/dist/components/Scope.d.ts +38 -1
  57. package/dist/components/Scope.js +117 -28
  58. package/dist/components/Scope.js.map +1 -1
  59. package/dist/components/componentLoader.d.ts +2 -6
  60. package/dist/components/componentLoader.js +160 -18
  61. package/dist/components/componentLoader.js.map +1 -1
  62. package/dist/components/deployLifecycle.d.ts +6 -2
  63. package/dist/components/deployLifecycle.js +109 -31
  64. package/dist/components/deployLifecycle.js.map +1 -1
  65. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  66. package/dist/components/mcp/toolRegistry.js +10 -0
  67. package/dist/components/mcp/toolRegistry.js.map +1 -1
  68. package/dist/components/mcp/tools/application.js +11 -5
  69. package/dist/components/mcp/tools/application.js.map +1 -1
  70. package/dist/components/mcp/tools/operations.js +9 -0
  71. package/dist/components/mcp/tools/operations.js.map +1 -1
  72. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  73. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  74. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  75. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  76. package/dist/components/operations.js +6 -6
  77. package/dist/components/operations.js.map +1 -1
  78. package/dist/components/operationsValidation.js +34 -2
  79. package/dist/components/operationsValidation.js.map +1 -1
  80. package/dist/components/scopeMount.d.ts +86 -0
  81. package/dist/components/scopeMount.js +131 -0
  82. package/dist/components/scopeMount.js.map +1 -0
  83. package/dist/config/configUtils.js +13 -9
  84. package/dist/config/configUtils.js.map +1 -1
  85. package/dist/dataLayer/backupManifest.d.ts +26 -0
  86. package/dist/dataLayer/backupManifest.js +97 -0
  87. package/dist/dataLayer/backupManifest.js.map +1 -0
  88. package/dist/dataLayer/blobBackup.d.ts +87 -0
  89. package/dist/dataLayer/blobBackup.js +282 -0
  90. package/dist/dataLayer/blobBackup.js.map +1 -0
  91. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  92. package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
  93. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  94. package/dist/dataLayer/hdbInfoController.js +4 -0
  95. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  96. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  97. package/dist/dataLayer/restoreMarker.js +261 -0
  98. package/dist/dataLayer/restoreMarker.js.map +1 -0
  99. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  100. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  101. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  102. package/dist/dataLayer/schemaDescribe.js +2 -1
  103. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  104. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  105. package/dist/resources/DatabaseTransaction.js +282 -109
  106. package/dist/resources/DatabaseTransaction.js.map +1 -1
  107. package/dist/resources/ResourceInterface.d.ts +8 -5
  108. package/dist/resources/ResourceInterface.js.map +1 -1
  109. package/dist/resources/Resources.js +22 -4
  110. package/dist/resources/Resources.js.map +1 -1
  111. package/dist/resources/Table.d.ts +6 -5
  112. package/dist/resources/Table.js +114 -37
  113. package/dist/resources/Table.js.map +1 -1
  114. package/dist/resources/analytics/write.js +6 -6
  115. package/dist/resources/analytics/write.js.map +1 -1
  116. package/dist/resources/blob.d.ts +8 -1
  117. package/dist/resources/blob.js +31 -14
  118. package/dist/resources/blob.js.map +1 -1
  119. package/dist/resources/databases.d.ts +59 -1
  120. package/dist/resources/databases.js +386 -41
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/jsResource.d.ts +4 -26
  123. package/dist/resources/jsResource.js +5 -59
  124. package/dist/resources/jsResource.js.map +1 -1
  125. package/dist/resources/models/Models.d.ts +11 -1
  126. package/dist/resources/models/Models.js +10 -1
  127. package/dist/resources/models/Models.js.map +1 -1
  128. package/dist/resources/models/backendRegistry.d.ts +9 -0
  129. package/dist/resources/models/backendRegistry.js +10 -0
  130. package/dist/resources/models/backendRegistry.js.map +1 -1
  131. package/dist/resources/models/openaiStream.d.ts +16 -1
  132. package/dist/resources/models/openaiStream.js +113 -21
  133. package/dist/resources/models/openaiStream.js.map +1 -1
  134. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  135. package/dist/resources/models/v1/chatCompletions.js +115 -0
  136. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  137. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  138. package/dist/resources/models/v1/embeddings.js +71 -0
  139. package/dist/resources/models/v1/embeddings.js.map +1 -0
  140. package/dist/resources/models/v1/errors.d.ts +54 -0
  141. package/dist/resources/models/v1/errors.js +130 -0
  142. package/dist/resources/models/v1/errors.js.map +1 -0
  143. package/dist/resources/models/v1/index.d.ts +36 -0
  144. package/dist/resources/models/v1/index.js +75 -0
  145. package/dist/resources/models/v1/index.js.map +1 -0
  146. package/dist/resources/models/v1/models.d.ts +26 -0
  147. package/dist/resources/models/v1/models.js +44 -0
  148. package/dist/resources/models/v1/models.js.map +1 -0
  149. package/dist/resources/models/v1/translation.d.ts +133 -0
  150. package/dist/resources/models/v1/translation.js +298 -0
  151. package/dist/resources/models/v1/translation.js.map +1 -0
  152. package/dist/resources/roles.d.ts +1 -1
  153. package/dist/resources/roles.js +54 -7
  154. package/dist/resources/roles.js.map +1 -1
  155. package/dist/resources/transaction.js +0 -3
  156. package/dist/resources/transaction.js.map +1 -1
  157. package/dist/security/jsLoader.js +84 -33
  158. package/dist/security/jsLoader.js.map +1 -1
  159. package/dist/security/role.js +4 -0
  160. package/dist/security/role.js.map +1 -1
  161. package/dist/security/superUserGuard.d.ts +7 -0
  162. package/dist/security/superUserGuard.js +23 -0
  163. package/dist/security/superUserGuard.js.map +1 -0
  164. package/dist/security/tokenAuthentication.d.ts +0 -1
  165. package/dist/security/tokenAuthentication.js +6 -2
  166. package/dist/security/tokenAuthentication.js.map +1 -1
  167. package/dist/security/user.d.ts +6 -1
  168. package/dist/security/user.js +23 -1
  169. package/dist/security/user.js.map +1 -1
  170. package/dist/server/REST.js +25 -9
  171. package/dist/server/REST.js.map +1 -1
  172. package/dist/server/fastifyRoutes.js +15 -1
  173. package/dist/server/fastifyRoutes.js.map +1 -1
  174. package/dist/server/http.d.ts +23 -2
  175. package/dist/server/http.js +99 -12
  176. package/dist/server/http.js.map +1 -1
  177. package/dist/server/itc/serverHandlers.js +7 -1
  178. package/dist/server/itc/serverHandlers.js.map +1 -1
  179. package/dist/server/jobs/jobProcess.js +20 -1
  180. package/dist/server/jobs/jobProcess.js.map +1 -1
  181. package/dist/server/jobs/jobRunner.js +10 -0
  182. package/dist/server/jobs/jobRunner.js.map +1 -1
  183. package/dist/server/jobs/jobs.js +11 -0
  184. package/dist/server/jobs/jobs.js.map +1 -1
  185. package/dist/server/middlewareChain.d.ts +10 -1
  186. package/dist/server/middlewareChain.js +81 -21
  187. package/dist/server/middlewareChain.js.map +1 -1
  188. package/dist/server/operationsServer.d.ts +0 -2
  189. package/dist/server/operationsServer.js.map +1 -1
  190. package/dist/server/serverHelpers/multipartParser.js +9 -0
  191. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  192. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  193. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  194. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  195. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  196. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  197. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  198. package/dist/server/serverHelpers/serverHandlers.js +27 -7
  199. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  200. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  201. package/dist/server/serverHelpers/serverUtilities.js +21 -7
  202. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  203. package/dist/server/static.js +68 -21
  204. package/dist/server/static.js.map +1 -1
  205. package/dist/server/storageReclamation.d.ts +17 -0
  206. package/dist/server/storageReclamation.js +90 -6
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/socketRouter.js +20 -0
  209. package/dist/server/threads/socketRouter.js.map +1 -1
  210. package/dist/server/threads/threadServer.js +12 -0
  211. package/dist/server/threads/threadServer.js.map +1 -1
  212. package/dist/sqlEngine/diff/differential.js +7 -3
  213. package/dist/sqlEngine/diff/differential.js.map +1 -1
  214. package/dist/sqlTranslator/index.js +6 -1
  215. package/dist/sqlTranslator/index.js.map +1 -1
  216. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  217. package/dist/upgrade/upgradePrompt.js +22 -3
  218. package/dist/upgrade/upgradePrompt.js.map +1 -1
  219. package/dist/utility/OperationFunctionCaller.js +25 -6
  220. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  221. package/dist/utility/errors/commonErrors.d.ts +1 -0
  222. package/dist/utility/errors/commonErrors.js +1 -0
  223. package/dist/utility/errors/commonErrors.js.map +1 -1
  224. package/dist/utility/hdbTerms.d.ts +13 -1
  225. package/dist/utility/hdbTerms.js +13 -1
  226. package/dist/utility/hdbTerms.js.map +1 -1
  227. package/dist/utility/install/installer.js +32 -0
  228. package/dist/utility/install/installer.js.map +1 -1
  229. package/dist/utility/logging/harper_logger.d.ts +47 -0
  230. package/dist/utility/logging/harper_logger.js +803 -38
  231. package/dist/utility/logging/harper_logger.js.map +1 -1
  232. package/dist/utility/operation_authorization.js +13 -3
  233. package/dist/utility/operation_authorization.js.map +1 -1
  234. package/npm-shrinkwrap.json +198 -193
  235. package/package.json +12 -7
  236. package/resources/DESIGN.md +2 -0
  237. package/resources/DatabaseTransaction.ts +310 -104
  238. package/resources/ResourceInterface.ts +8 -5
  239. package/resources/Resources.ts +22 -4
  240. package/resources/Table.ts +341 -263
  241. package/resources/analytics/write.ts +22 -20
  242. package/resources/blob.ts +31 -14
  243. package/resources/databases.ts +387 -46
  244. package/resources/jsResource.ts +5 -62
  245. package/resources/models/Models.ts +14 -1
  246. package/resources/models/backendRegistry.ts +10 -0
  247. package/resources/models/openaiStream.ts +131 -19
  248. package/resources/models/v1/chatCompletions.ts +128 -0
  249. package/resources/models/v1/embeddings.ts +70 -0
  250. package/resources/models/v1/errors.ts +141 -0
  251. package/resources/models/v1/index.ts +72 -0
  252. package/resources/models/v1/models.ts +53 -0
  253. package/resources/models/v1/translation.ts +362 -0
  254. package/resources/roles.ts +67 -7
  255. package/resources/transaction.ts +0 -3
  256. package/security/jsLoader.ts +84 -30
  257. package/security/role.ts +7 -0
  258. package/security/superUserGuard.ts +20 -0
  259. package/security/tokenAuthentication.ts +6 -3
  260. package/security/user.ts +26 -1
  261. package/server/DESIGN.md +61 -34
  262. package/server/REST.ts +25 -9
  263. package/server/fastifyRoutes.ts +20 -1
  264. package/server/http.ts +100 -13
  265. package/server/itc/serverHandlers.js +7 -1
  266. package/server/jobs/jobProcess.ts +18 -1
  267. package/server/jobs/jobRunner.ts +10 -0
  268. package/server/jobs/jobs.ts +11 -0
  269. package/server/middlewareChain.ts +79 -20
  270. package/server/operationsServer.ts +0 -2
  271. package/server/serverHelpers/multipartParser.ts +9 -0
  272. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  273. package/server/serverHelpers/registeredOperations.ts +19 -15
  274. package/server/serverHelpers/serverHandlers.js +28 -7
  275. package/server/serverHelpers/serverUtilities.ts +29 -7
  276. package/server/static.ts +87 -23
  277. package/server/storageReclamation.ts +104 -8
  278. package/server/threads/socketRouter.ts +20 -0
  279. package/server/threads/threadServer.js +11 -0
  280. package/sqlTranslator/index.ts +6 -1
  281. package/static/defaultConfig.yaml +3 -0
  282. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
  283. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
  284. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
  285. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  286. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
  287. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  288. package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
  289. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
  290. package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
  291. package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
  292. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
  293. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  294. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
  295. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
  296. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
  297. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
  298. package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
  299. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
  300. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
  301. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  302. package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
  303. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
  304. package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
  305. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
  306. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  307. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  308. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
  309. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
  310. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  311. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  312. package/studio/web/index.html +1 -1
  313. package/upgrade/upgradePrompt.ts +22 -3
  314. package/utility/OperationFunctionCaller.ts +24 -3
  315. package/utility/errors/commonErrors.ts +2 -0
  316. package/utility/hdbTerms.ts +13 -1
  317. package/utility/install/installer.ts +37 -0
  318. package/utility/logging/harper_logger.ts +786 -41
  319. package/utility/operation_authorization.ts +31 -3
  320. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -34,12 +34,15 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ImmediateTransaction = exports.DatabaseTransaction = exports.TRANSACTION_STATE = void 0;
37
+ exports.trackOutstandingCommit = trackOutstandingCommit;
38
+ exports.getOutstandingCommits = getOutstandingCommits;
37
39
  exports.setCommitLatencyRecorder = setCommitLatencyRecorder;
38
40
  exports.getTransactionQueueDepths = getTransactionQueueDepths;
39
41
  exports.replicationConfirmation = replicationConfirmation;
40
42
  exports.transactionOpenTooLongError = transactionOpenTooLongError;
41
43
  exports.priorStagedWrite = priorStagedWrite;
42
44
  exports.writeKeyId = writeKeyId;
45
+ exports.resetReplayedWritesWarning = resetReplayedWritesWarning;
43
46
  exports.setTxnExpiration = setTxnExpiration;
44
47
  const blob_ts_1 = require("./blob.js");
45
48
  const commonUtility_ts_1 = require("../utility/lmdb/commonUtility.js");
@@ -69,22 +72,91 @@ const MAX_RETRIES = 40;
69
72
  // Cap the per-retry backoff so replication-applied transactions, which retry conflicts without a
70
73
  // cap (see the commit rejection handler), don't grow the delay unbounded.
71
74
  const MAX_RETRY_DELAY_MS = 1000;
72
- let outstandingCommit, outstandingCommitStart;
73
- // Identity references for the commit that armed `outstandingCommit`, kept for the one-time
74
- // checkOverloaded() log below (harper#2001) — `outstandingCommit` itself is otherwise anonymous, so
75
- // a stuck commit gives no indication of which database/table/resource to investigate. Snapshotted
76
- // at arm time (not read from `this.writes`/`this.startedFrom` lazily off the DatabaseTransaction
77
- // object) because that object can be reused for a later immediate commit while the original native
78
- // commit is still wedged — its resolve handler runs clearWrites() on the SAME object, which would
79
- // blank or replace the identity out from under a deferred read. Three reference assignments, no
80
- // allocation, and no lingering reference to the writes/entries graph (the native transaction handle
81
- // is retained, but its lifetime is already bounded by the pending outstandingCommit promise).
82
- let outstandingCommitStore;
83
- let outstandingCommitStartedFrom;
84
- let outstandingCommitNativeTransaction;
85
- // Ensures the checkOverloaded() rejection is logged once per stuck commit, not once per rejected
86
- // request under load a wedged thread can reject hundreds of requests per second.
87
- let outstandingCommitLogged = false;
75
+ let oldestOutstandingCommit;
76
+ let newestOutstandingCommit;
77
+ let outstandingCommitCount = 0;
78
+ // Caps the stuck-commit log (checkOverloaded() below) to at most one line per this interval across
79
+ // the whole thread, regardless of how many distinct commits individually cross the threshold — see
80
+ // the comment at the log site for why a per-commit-only dedup isn't enough under sustained overload.
81
+ const OVERLOAD_LOG_MIN_INTERVAL_MS = 1000;
82
+ let lastOverloadLogAt = -Infinity;
83
+ // Track a submitted commit until it settles. Every attempt is tracked unconditionally: a
84
+ // coordinated retry round and a chained second-store commit are both issued from inside the
85
+ // preceding commit's own resolve handler, which runs before any reaction that could release a
86
+ // single shared slot — so anything conditional on "is something already outstanding" skips them
87
+ // and leaves a wedged retry or chain invisible to checkOverloaded() forever. Each attempt is timed
88
+ // from its own submission, keeping the overload window per-attempt rather than cumulative over a
89
+ // retry ladder. `.then(untrack, untrack)` also marks an ERR_BUSY rejection handled, so this
90
+ // tracking never surfaces as an unhandled rejection alongside the caller's own handler.
91
+ // Exported only so unit tests can drive the list with controllable promises: the unlink order that
92
+ // matters (a middle or tail node settling first) cannot be forced through real writes, and a node
93
+ // left linked would 503 every write on this thread forever. commit() below is the sole caller.
94
+ // Also the single source for write-queue-depth accounting (getTransactionQueueDepths below): every
95
+ // native commit this function tracks is, by definition, exactly the write-queue backlog — see the
96
+ // comment there for why that used to be a second, separately-maintained counter.
97
+ // `store`/`startedFrom`/`nativeTransaction` are the identity snapshot for checkOverloaded()'s stuck-
98
+ // commit log (harper#2001); omit them (as the test seam below does) when a caller has none to give.
99
+ function trackOutstandingCommit(commitResolution, store, startedFrom, nativeTransaction) {
100
+ // Guards against a future caller passing a non-Promise: today commit() always hands this a real
101
+ // Promise, but an unguarded link here would leave a node permanently wedged in the list (503ing
102
+ // every write on this thread) with no settlement to ever unlink it.
103
+ if (typeof commitResolution?.then !== 'function')
104
+ return;
105
+ const outstanding = {
106
+ start: performance.now(),
107
+ prev: newestOutstandingCommit,
108
+ next: undefined,
109
+ store,
110
+ startedFrom,
111
+ nativeTransaction,
112
+ logged: false,
113
+ };
114
+ if (newestOutstandingCommit != null)
115
+ newestOutstandingCommit.next = outstanding;
116
+ else
117
+ oldestOutstandingCommit = outstanding;
118
+ newestOutstandingCommit = outstanding;
119
+ outstandingCommitCount++;
120
+ // Doubles as the write-queue-depth high-water mark (see getTransactionQueueDepths): every
121
+ // outstanding commit is a write-queue entry, so the peak of one is the peak of the other.
122
+ if (outstandingCommitCount > writeTxnQueueDepthHighWater)
123
+ writeTxnQueueDepthHighWater = outstandingCommitCount;
124
+ // Guards against double-untracking the same node: `.then(untrack, untrack)` below means a promise
125
+ // that both resolves and later has its rejection handler independently triggered (or is tracked via
126
+ // a shared/misused resolution) could otherwise run the unlink twice, corrupting the list or driving
127
+ // outstandingCommitCount negative.
128
+ let untracked = false;
129
+ const untrack = () => {
130
+ if (untracked)
131
+ return;
132
+ untracked = true;
133
+ if (outstanding.prev != null)
134
+ outstanding.prev.next = outstanding.next;
135
+ else
136
+ oldestOutstandingCommit = outstanding.next;
137
+ if (outstanding.next != null)
138
+ outstanding.next.prev = outstanding.prev;
139
+ else
140
+ newestOutstandingCommit = outstanding.prev;
141
+ outstandingCommitCount--;
142
+ };
143
+ commitResolution.then(untrack, untrack);
144
+ }
145
+ /**
146
+ * How many write commits are outstanding on this thread and how long the oldest has been waiting
147
+ * (`oldestAgeMs` is undefined when none is). `oldestAgeMs` is exactly the value checkOverloaded()
148
+ * rejects on, exposed so a commit that never settles (harper#2001) can be observed directly rather
149
+ * than inferred from the 503s it eventually produces.
150
+ */
151
+ function getOutstandingCommits() {
152
+ return {
153
+ count: outstandingCommitCount,
154
+ oldestAgeMs: oldestOutstandingCommit ? performance.now() - oldestOutstandingCommit.start : undefined,
155
+ };
156
+ }
157
+ // Once per process: committing under open read iterators forces a write replay, so the warning is
158
+ // about the caller's pattern, not the individual commit.
159
+ let replayedWritesWarned = false;
88
160
  // The analytics module registers a recorder here at load (dependency inversion, mirroring
89
161
  // `replicationConfirmation` below) so the storage layer doesn't statically import the analytics/server
90
162
  // modules. Unset until analytics loads, and when analytics is disabled the recorder call is cheap.
@@ -117,29 +189,19 @@ function recordCommitLatency(commitResolution, submittedAt) {
117
189
  }
118
190
  // Queue-depth gauges surfaced through the analytics pipeline (write-transaction-queue-depth /
119
191
  // read-transaction-queue-depth). Per-thread state; the analytics aggregator sums across threads.
120
- // `writeTxnQueueDepth` counts write commits handed to the storage engine but not yet resolved
121
- // this is the backlog that, when it drains too slowly, produces the "Outstanding write transactions
122
- // have too long of queue" overload error. Read depth is derived from the live `trackedTxns` set
123
- // (every tracked transaction holds an open read snapshot). We also retain a high-water mark per
124
- // sampling window because the queue can fill and drain within a single (~1s) analytics period, so an
192
+ // The write depth is `outstandingCommitCount` itself (maintained above by trackOutstandingCommit)
193
+ // write commits handed to the storage engine but not yet resolved are exactly the same set of native
194
+ // commits the overload check tracks, and keeping one counter instead of two removes the duplicate
195
+ // per-commit bookkeeping (and the drift risk: a code path that updates one but not the other, as the
196
+ // replay path did before this fix). Read depth is derived from the live `trackedTxns` set (every
197
+ // tracked transaction holds an open read snapshot). We also retain a high-water mark per sampling
198
+ // window because the queue can fill and drain within a single (~1s) analytics period, so an
125
199
  // instantaneous sample taken at emit time would routinely miss the spike operators need to see.
126
200
  // RocksDB-write-path only: LMDB routes through the separate LMDBTransaction.commit()/getReadTxn()
127
201
  // overrides (resources/LMDBTransaction.ts), which maintain their own unrelated `trackedTxns` set and
128
202
  // do not call into this accounting.
129
- let writeTxnQueueDepth = 0;
130
203
  let writeTxnQueueDepthHighWater = 0;
131
204
  let readTxnQueueDepthHighWater = 0;
132
- function enterWriteQueue() {
133
- if (++writeTxnQueueDepth > writeTxnQueueDepthHighWater)
134
- writeTxnQueueDepthHighWater = writeTxnQueueDepth;
135
- }
136
- function leaveWriteQueue() {
137
- // Floor at zero: accounting is balanced by construction (every enterWriteQueue has exactly one
138
- // matching settlement), but the guard is cheap insurance against a future call-site imbalance
139
- // producing a negative depth that would corrupt every subsequent sample.
140
- if (writeTxnQueueDepth > 0)
141
- writeTxnQueueDepth--;
142
- }
143
205
  /**
144
206
  * Returns the current write/read transaction queue depths for this thread along with the high-water
145
207
  * mark observed since the previous call, then resets the high-water marks to the current depth so the
@@ -150,12 +212,12 @@ function getTransactionQueueDepths() {
150
212
  // dominates the current size here — no need to reconcile against `readDepth` before reporting.
151
213
  const readDepth = trackedTxns.size;
152
214
  const depths = {
153
- writeDepth: writeTxnQueueDepth,
215
+ writeDepth: outstandingCommitCount,
154
216
  writeMaxDepth: writeTxnQueueDepthHighWater,
155
217
  readDepth,
156
218
  readMaxDepth: readTxnQueueDepthHighWater,
157
219
  };
158
- writeTxnQueueDepthHighWater = writeTxnQueueDepth;
220
+ writeTxnQueueDepthHighWater = outstandingCommitCount;
159
221
  readTxnQueueDepthHighWater = readDepth;
160
222
  return depths;
161
223
  }
@@ -227,7 +289,18 @@ class DatabaseTransaction {
227
289
  replicatedConfirmation;
228
290
  getReadTxn(disableSnapshot) {
229
291
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
230
- this.timeout = txnExpiration; // reset the timeout
292
+ // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so
293
+ // while no uncommitted writes are held: staged writes hold write intents that other writers'
294
+ // coordinated-retry commits park on, so a handler that wrote once and then only reads — an
295
+ // orphaned long-poll whose client had already gone, in harper#2001 — must not keep those
296
+ // intents alive by reading. A transaction that keeps writing stays alive; so does a purely
297
+ // read-only one. A committed transaction re-arms too: its intents went with the commit, and
298
+ // the monitor bounds its retained read snapshot separately.
299
+ // `writes`/`next` are checked inline first: this is a hot path and the dominant case is a
300
+ // single-store transaction that has never written.
301
+ if ((this.writes.length === 0 && !this.next) || this.open !== exports.TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
302
+ this.timeout = txnExpiration;
303
+ }
231
304
  if (this.transaction) {
232
305
  if (this.transaction.openTimer)
233
306
  this.transaction.openTimer = 0;
@@ -275,6 +348,7 @@ class DatabaseTransaction {
275
348
  trackedTxns.delete(this);
276
349
  this.transaction?.abort();
277
350
  this.transaction = null;
351
+ this.completeDeferredContextRelease();
278
352
  }
279
353
  }
280
354
  /**
@@ -293,6 +367,20 @@ class DatabaseTransaction {
293
367
  harperLogger.debug?.('releasing timed-out read transaction', error);
294
368
  }
295
369
  this.transaction = null;
370
+ this.completeDeferredContextRelease();
371
+ }
372
+ /**
373
+ * Complete a context release that releaseContext() deferred because outstanding read iterators
374
+ * were still using this transaction (see releaseContext()) — called once the last one drains,
375
+ * whether that happens naturally (doneReadTxn()) or is forced by the long-transaction monitor
376
+ * (releaseReadTxn()).
377
+ */
378
+ completeDeferredContextRelease() {
379
+ if (!this.pendingContextRelease)
380
+ return;
381
+ this.pendingContextRelease = false;
382
+ if (this.#context?.transaction === this)
383
+ this.#context.transaction = null;
296
384
  }
297
385
  disregardReadTxn() {
298
386
  if (--this.readTxnRefCount === 0 && this.readTxnsUsed === 1) {
@@ -324,22 +412,69 @@ class DatabaseTransaction {
324
412
  this.writes = [];
325
413
  this.writesByKey = undefined;
326
414
  }
415
+ /**
416
+ * Drop this transaction's back-reference from its context once completed (commit or abort),
417
+ * so a long-lived context (e.g. an MQTT subscription context held open for the life of a
418
+ * suspended delivery loop) doesn't keep pinning a finished transaction in memory. Guarded by
419
+ * identity: a context already re-pointed at a different (e.g. reused) transaction is untouched.
420
+ *
421
+ * `final` must be false for an in-callback explicit `context.transaction.commit()` — the
422
+ * "commit in the middle" pattern intentionally keeps recommitting and adding writes to the
423
+ * SAME instance (see the comment above about a transaction being "reused and committed
424
+ * again"), so releasing here would strand those later writes with no transaction to join.
425
+ * Only resources/transaction.ts's own wrapper commit (`{ doneWriting: true }`, once the
426
+ * caller's callback has fully returned) and abort() are truly final.
427
+ *
428
+ * A final commit can still have outstanding read iterators streaming through this.transaction
429
+ * (see the outstanding-iterators branch in commit()) — those keep this instance meaningfully
430
+ * alive (a fresh write on the same context must not join a DIFFERENT, already-replayed
431
+ * transaction) until doneReadTxn() drains the last one, so the release is deferred to there.
432
+ *
433
+ * Sets `.transaction` to `null` rather than deleting the property: `Context.transaction` is
434
+ * typed `DatabaseTransaction | null | undefined` precisely to document this released state, and
435
+ * `delete` would repeatedly force a long-lived, hot context (e.g. an MQTT subscription context
436
+ * releasing/reattaching a transaction per message) into V8's slower dictionary-mode property
437
+ * storage.
438
+ */
439
+ releaseContext(final) {
440
+ if (!final)
441
+ return;
442
+ if (this.readTxnsUsed > 0) {
443
+ this.pendingContextRelease = true;
444
+ return;
445
+ }
446
+ if (this.#context?.transaction === this)
447
+ this.#context.transaction = null;
448
+ }
327
449
  checkOverloaded() {
328
- if (outstandingCommit &&
450
+ if (oldestOutstandingCommit &&
329
451
  !this.overloadChecked &&
330
- performance.now() - outstandingCommitStart > MAX_OUTSTANDING_TXN_DURATION) {
331
- if (!outstandingCommitLogged) {
452
+ performance.now() - oldestOutstandingCommit.start > MAX_OUTSTANDING_TXN_DURATION) {
453
+ const now = performance.now();
454
+ // Also rate-limited across the whole overload episode (not just deduped per commit): under
455
+ // sustained heavy load, many distinct commits can each individually age past the limit in
456
+ // quick succession as earlier ones finally settle, which without this cap would turn one
457
+ // overload episode into a growing stream of ERROR lines — the same "flood" harper#2001's
458
+ // original per-request log was fixed to avoid, just shifted from per-request to per-commit.
459
+ // A commit skipped by the cooldown is NOT marked `logged`, so it still gets a log later if
460
+ // it's still the oldest once the cooldown clears, rather than going silent forever.
461
+ if (!oldestOutstandingCommit.logged && now - lastOverloadLogAt > OVERLOAD_LOG_MIN_INTERVAL_MS) {
332
462
  // Log once per stuck commit (not once per rejected request, harper#2001): a wedged
333
463
  // thread otherwise logs nothing at all server-side while rejecting every write with a
334
- // 503, which was the single biggest obstacle to root-causing a recurrence.
335
- outstandingCommitLogged = true;
336
- const nativeTransactionId = outstandingCommitNativeTransaction?.id;
464
+ // 503, which was the single biggest obstacle to root-causing a recurrence. The flag lives
465
+ // on the node itself, so if THIS commit settles while still over the limit and a
466
+ // different one is now oldest, that one logs too instead of staying silent forever.
467
+ oldestOutstandingCommit.logged = true;
468
+ lastOverloadLogAt = now;
469
+ const nativeTransactionId = oldestOutstandingCommit.nativeTransaction?.id;
470
+ const store = oldestOutstandingCommit.store;
471
+ const startedFrom = oldestOutstandingCommit.startedFrom;
337
472
  harperLogger.error(`Rejecting writes on this thread: a commit has been outstanding for ` +
338
- `${Math.round(performance.now() - outstandingCommitStart)}ms (exceeds the ` +
339
- `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${outstandingCommitStore?.rootStore?.databaseName ?? '?'}.${outstandingCommitStore?.name ?? '?'}` +
473
+ `${Math.round(now - oldestOutstandingCommit.start)}ms (exceeds the ` +
474
+ `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${store?.rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
340
475
  (nativeTransactionId !== undefined ? ` (transaction ${nativeTransactionId})` : '') +
341
- (outstandingCommitStartedFrom?.resourceName
342
- ? `, started from ${outstandingCommitStartedFrom.resourceName}${outstandingCommitStartedFrom.method ? '.' + outstandingCommitStartedFrom.method : ''}`
476
+ (startedFrom?.resourceName
477
+ ? `, started from ${startedFrom.resourceName}${startedFrom.method ? '.' + startedFrom.method : ''}`
343
478
  : '') +
344
479
  `. Further record updates and publishes from new application requests on this thread ` +
345
480
  `will be rejected with 503 until the commit settles or the process is restarted (deletes, ` +
@@ -352,6 +487,13 @@ class DatabaseTransaction {
352
487
  addWrite(operation) {
353
488
  if (this.timedOut)
354
489
  throw transactionOpenTooLongError();
490
+ // A write is activity: it re-arms the idle limit on this link even though the reads it
491
+ // performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive
492
+ // and only an idle one holding write intents is reaped.
493
+ this.timeout = txnExpiration;
494
+ // Independent write-recency signal for chainStillActive (see the field comment) — reads never
495
+ // touch this, only writes do.
496
+ this.writeTimeout = txnExpiration;
355
497
  this.linkWrite(operation);
356
498
  this.writes.push(operation);
357
499
  if (!operation.deferSave) {
@@ -481,6 +623,12 @@ class DatabaseTransaction {
481
623
  this.writes = this.writes.filter((write) => write); // filter out removed entries
482
624
  if (this.writes.length > 0) {
483
625
  if (!options.transaction) {
626
+ if (!replayedWritesWarned) {
627
+ replayedWritesWarned = true;
628
+ harperLogger.warn?.(`Committing while read iterators are still open: ${this.writes.length} staged write(s) must be re-staged and committed on a second transaction, doubling their write work` +
629
+ (this.startedFrom ? `, from ${this.startedFrom.resourceName}.${this.startedFrom.method}` : '') +
630
+ `. Fully consume (or close) iterators before committing to avoid this. Logged once per process.`);
631
+ }
484
632
  // Deliberately NOT marked isRetry and NOT carrying over the original's onCommit:
485
633
  // audit/txn-log entries batch natively on the transaction they were staged into and
486
634
  // are only durably written by that transaction's commit attempt (an abort discards
@@ -501,6 +649,29 @@ class DatabaseTransaction {
501
649
  // re-staged the writes into it
502
650
  commitResolution = transaction.commit();
503
651
  recordCommitLatency(commitResolution, performance.now());
652
+ // Write-queue-depth accounting for this replay commit happens uniformly below, via
653
+ // trackOutstandingCommit(commitResolution) — see that function's comment. Omitting
654
+ // dedicated accounting here (as a prior version of this replay path did) used to leave
655
+ // write-transaction-queue-depth, the one metric that can observe a commit that never
656
+ // settles (harper#2001), reading zero for exactly this path.
657
+ }
658
+ // No commit will ever run on the retained handle — the replay above owns these
659
+ // writes — so this is the only place its write intents can be released. Left in
660
+ // place, other writers' coordinated-retry commits park on them until the last
661
+ // iterator finishes (harper#2001). Reads through the handle, including
662
+ // read-your-own-writes, keep working. Once only: a coordinated-retry or backoff
663
+ // round re-enters this branch on the same retained handle. Fenced like the other
664
+ // post-submit steps here: the replay commit is already in flight, so a throw must
665
+ // not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7
666
+ // lacks the method.
667
+ if (!this.writesAbandoned) {
668
+ this.writesAbandoned = true;
669
+ try {
670
+ this.transaction?.abandonWrites?.();
671
+ }
672
+ catch (error) {
673
+ harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
674
+ }
504
675
  }
505
676
  }
506
677
  else {
@@ -516,31 +687,19 @@ class DatabaseTransaction {
516
687
  // Promise<number | void>; it is handled in the resolve callback below.
517
688
  commitResolution = transaction.commit();
518
689
  // Record how long this commit stays outstanding (submit → settle) as a distribution
519
- // metric. This is the same clock the overload check uses (outstandingCommitStart is
520
- // stamped at submit), so a rising p99/p999 is the leading indicator for the
690
+ // metric. This is the same clock the overload check uses (trackOutstandingCommit
691
+ // stamps each attempt at submit), so a rising p99/p999 is the leading indicator for the
521
692
  // "Outstanding write transactions have too long of queue" (503) rejection. A transient-
522
- // conflict retry rejects this promise and issues a fresh commit(), but outstandingCommit
523
- // only re-arms for the backoff path (retries > 2, below) — the coordinated-retry and
524
- // retries<=2 paths re-commit synchronously while outstandingCommit is still set, so
525
- // recording per attempt here counts more attempts than the overload check ever arms for.
693
+ // conflict retry rejects this promise and issues a fresh commit(), which is tracked as
694
+ // its own attempt, so recording per attempt matches the overload semantics.
526
695
  // commitResolution's declared type (Promise<number | void> | void) doesn't narrow to
527
696
  // Promise<void> here because the widening union defeats flow analysis on the prior
528
697
  // cast assignment; re-assert it — this branch's commit() result is always a Promise.
529
698
  recordCommitLatency(commitResolution, performance.now());
530
- // Count this commit against the write queue depth until the storage engine
531
- // resolves it. A transient-conflict retry rejects this promise and issues a
532
- // fresh commit() (re-entering here), so the enter/leave stays balanced. leaveWriteQueue
533
- // never throws, so the settled promise resolves and needs no rejection handling of its own.
534
- // The thenable guard protects against a future caller passing a non-Promise
535
- // `commitResolution` (today it is always rocksdb-js's async Transaction.commit()
536
- // result, guaranteed to be a Promise).
537
- enterWriteQueue();
538
- if (commitResolution && typeof commitResolution.then === 'function') {
539
- commitResolution.then(leaveWriteQueue, leaveWriteQueue);
540
- }
541
- else {
542
- leaveWriteQueue();
543
- }
699
+ // Write-queue-depth accounting for this commit happens uniformly below, via
700
+ // trackOutstandingCommit(commitResolution) see that function's comment. A
701
+ // transient-conflict retry rejects this promise and issues a fresh commit()
702
+ // (re-entering here), which trackOutstandingCommit tracks as its own attempt.
544
703
  }
545
704
  else {
546
705
  try {
@@ -556,43 +715,10 @@ class DatabaseTransaction {
556
715
  }
557
716
  }
558
717
  if (commitResolution) {
559
- // Known gap (pre-existing, not introduced here): this branch only arms when
560
- // outstandingCommit is currently unset. A coordinated-retry re-commit (below) or a
561
- // chained this.next.commit() runs synchronously inside this same commit's .then
562
- // handler, one microtask before the .catch().finally() below clears outstandingCommit
563
- // — so a retry or a second store's commit that itself wedges is never armed, and
564
- // neither checkOverloaded() nor this log will ever see it. Only the first store's
565
- // first commit attempt is covered.
566
- if (!outstandingCommit) {
567
- outstandingCommit = commitResolution;
568
- outstandingCommitStart = performance.now();
569
- // Snapshot the store/origin now rather than reading them off `this` lazily in
570
- // checkOverloaded(): `this` (a DatabaseTransaction) can be reused for a later immediate
571
- // commit while this native commit is still wedged, and that later commit's resolve
572
- // handler runs clearWrites() on this SAME object — a lazy read would then blank or
573
- // mis-attribute the identity out from under the deferred log. Read the table off the
574
- // write itself, not this.db, which is whichever table first claimed this per-database
575
- // transaction in txnForContext and so can name the wrong table when a transaction spans
576
- // more than one table in the same database. Both are stable references (a Store, and an
577
- // object set once and never mutated), so this holds no allocation and no lingering
578
- // reference to the transaction/writes/entries graph.
579
- outstandingCommitStore = this.writes[0]?.store;
580
- outstandingCommitStartedFrom = this.startedFrom;
581
- outstandingCommitNativeTransaction = transaction;
582
- outstandingCommitLogged = false;
583
- outstandingCommit
584
- // if `commitResolution` rejects with and `ERR_BUSY` error, the retry logic
585
- // will correct course, but the reject will still be propagated on the
586
- // `outstandingCommit` promise and needs to be caught and silenced
587
- .catch(() => { })
588
- .finally(() => {
589
- outstandingCommit = null;
590
- outstandingCommitStore = undefined;
591
- outstandingCommitStartedFrom = undefined;
592
- outstandingCommitNativeTransaction = undefined;
593
- outstandingCommitLogged = false;
594
- });
595
- }
718
+ // Read the table off the write itself, not this.db, which is whichever table first
719
+ // claimed this per-database transaction in txnForContext and so can name the wrong
720
+ // table when a transaction spans more than one table in the same database.
721
+ trackOutstandingCommit(commitResolution, this.writes[0]?.store, this.startedFrom, transaction);
596
722
  const completions = [];
597
723
  return commitResolution.then((commitResult) => {
598
724
  if (commitResult === RETRY_NOW_VALUE) {
@@ -656,8 +782,7 @@ class DatabaseTransaction {
656
782
  // now reset transactions tracking; this transaction be reused and committed again
657
783
  this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
658
784
  this.clearWrites();
659
- if (this.#context?.resourceCache)
660
- this.#context.resourceCache = null;
785
+ this.releaseContext(!!options.doneWriting);
661
786
  this.next = null;
662
787
  let txnTime = this.timestamp;
663
788
  this.timestamp = 0; // reset the timestamp as well
@@ -734,6 +859,11 @@ class DatabaseTransaction {
734
859
  catch (abortError) {
735
860
  harperLogger.debug?.('aborting transaction after failed commit', abortError);
736
861
  }
862
+ // A terminal failure is just as final as a success — release the context's
863
+ // back-reference here too, or transaction.ts's onComplete() (which has no
864
+ // rejection handler of its own) would leave a long-lived context pinning this
865
+ // CLOSED wrapper forever.
866
+ this.releaseContext(!!options.doneWriting);
737
867
  throw error;
738
868
  }
739
869
  });
@@ -743,8 +873,7 @@ class DatabaseTransaction {
743
873
  (0, blob_ts_1.cleanupUnusedBlobs)(write.savedBlobs, (0, blob_ts_1.collectRetainedFileIds)(write.store.getEntry(write.key)?.value));
744
874
  }
745
875
  this.clearWrites();
746
- if (this.#context?.resourceCache)
747
- this.#context.resourceCache = null;
876
+ this.releaseContext(!!options.doneWriting);
748
877
  const txnResolution = {
749
878
  txnTime: this.timestamp,
750
879
  };
@@ -776,8 +905,13 @@ class DatabaseTransaction {
776
905
  }
777
906
  // reset the transaction
778
907
  this.clearWrites();
779
- if (this.#context?.resourceCache)
780
- this.#context.resourceCache = null;
908
+ // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
909
+ // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
910
+ // starting a fresh one) so the rest of the logical operation fails atomically via the
911
+ // poison check in addWrite()/commit(), rather than silently landing a later write on a
912
+ // brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
913
+ // make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
914
+ this.releaseContext(!this.timedOut);
781
915
  }
782
916
  /**
783
917
  * Give up on a chain of linked transactions after exhausting conflict retries: poison every link
@@ -899,9 +1033,33 @@ class ImmediateTransaction extends DatabaseTransaction {
899
1033
  }
900
1034
  exports.ImmediateTransaction = ImmediateTransaction;
901
1035
  let timer;
1036
+ /**
1037
+ * True when a link other than `txn` in the same multi-store chain was written recently enough to
1038
+ * still be active — i.e. its `writeTimeout` (set only by addWrite, see the field comment) hasn't
1039
+ * decayed to zero. Writes re-arm only the link that receives them, so a chain writing database B
1040
+ * while its head only reads A would otherwise be aborted by the head's own decay.
1041
+ */
1042
+ function chainStillActive(txn) {
1043
+ for (let link = txn.next; link; link = link.next) {
1044
+ // A write-only link (e.g. a blind write to a second database, never itself read) never calls
1045
+ // getReadTxn, so it's never added to trackedTxns and the main loop below never decays it.
1046
+ // Decay it here instead, so an idle write-only link eventually expires rather than keeping the
1047
+ // whole chain immortal (harper#2001's blind-write shape).
1048
+ if (!trackedTxns.has(link) && link.writeTimeout > 0)
1049
+ link.writeTimeout -= txnExpiration;
1050
+ if (link.writeTimeout > 0)
1051
+ return true;
1052
+ }
1053
+ return false;
1054
+ }
902
1055
  function startMonitoringTxns() {
903
1056
  timer = setInterval(function () {
904
1057
  for (const txn of trackedTxns) {
1058
+ // Decay write recency once per tick for every tracked link, independent of the `timeout`
1059
+ // branches below — a tracked link that keeps its own idle limit alive by reading must not
1060
+ // thereby keep chainStillActive believing it was written recently too.
1061
+ if (txn.writeTimeout > 0)
1062
+ txn.writeTimeout -= txnExpiration;
905
1063
  if (txn.timeout <= 0) {
906
1064
  const url = txn.getContext()?.url;
907
1065
  if (txn.open === exports.TRANSACTION_STATE.CLOSED) {
@@ -914,6 +1072,13 @@ function startMonitoringTxns() {
914
1072
  harperLogger.warn?.(`Read iterators held a committed transaction's snapshot past the open-transaction limit; releasing it, from table: ${txn.db?.name + (url ? ' path: ' + url : '')}`);
915
1073
  txn.releaseReadTxn();
916
1074
  }
1075
+ else if (txn.hasPendingWrites() && chainStillActive(txn)) {
1076
+ // A later link in the chain was written recently (writes re-arm only the link that
1077
+ // receives them, and a multi-store transaction can be writing database B while this
1078
+ // head only reads A). The logical transaction is still active, so re-arm this link
1079
+ // rather than aborting the whole chain out from under it.
1080
+ txn.timeout = txnExpiration;
1081
+ }
917
1082
  else if (txn.hasPendingWrites() && !txn.sourceApply && !txn.isReplay) {
918
1083
  // Abort and surface an error rather than force-committing a partial write set: silently
919
1084
  // committing on the application's behalf breaks atomicity and can leave orphaned
@@ -956,6 +1121,14 @@ function startMonitoringTxns() {
956
1121
  }, txnExpiration).unref();
957
1122
  }
958
1123
  startMonitoringTxns();
1124
+ /**
1125
+ * Test seam: re-arms the once-per-process replay warning. The whole unit suite shares one process,
1126
+ * so whichever test first drives a commit under open iterators consumes the warning for every test
1127
+ * after it.
1128
+ */
1129
+ function resetReplayedWritesWarning() {
1130
+ replayedWritesWarned = false;
1131
+ }
959
1132
  function setTxnExpiration(ms) {
960
1133
  clearInterval(timer);
961
1134
  txnExpiration = ms;