@harperfast/harper 5.2.0 → 5.2.2

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 (308) hide show
  1. package/bin/copyDb.ts +40 -11
  2. package/bin/harper.ts +20 -51
  3. package/bin/help.ts +216 -0
  4. package/components/Application.ts +236 -46
  5. package/components/ApplicationScope.ts +26 -0
  6. package/components/EntryHandler.ts +410 -105
  7. package/components/RuntimeModuleTracker.ts +189 -0
  8. package/components/Scope.ts +68 -24
  9. package/components/componentLoader.ts +70 -16
  10. package/components/deployLifecycle.ts +119 -33
  11. package/components/deploymentRecorder.ts +102 -11
  12. package/components/mcp/tools/operations.ts +6 -0
  13. package/components/operations.js +8 -6
  14. package/config/configUtils.ts +78 -20
  15. package/config/harperConfigEnvVars.ts +114 -17
  16. package/config-root.schema.json +10 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  18. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  19. package/dataLayer/hdbInfoController.ts +8 -0
  20. package/dataLayer/schemaDescribe.ts +2 -1
  21. package/dist/bin/copyDb.js +33 -9
  22. package/dist/bin/copyDb.js.map +1 -1
  23. package/dist/bin/harper.d.ts +6 -0
  24. package/dist/bin/harper.js +18 -50
  25. package/dist/bin/harper.js.map +1 -1
  26. package/dist/bin/help.d.ts +8 -0
  27. package/dist/bin/help.js +192 -0
  28. package/dist/bin/help.js.map +1 -0
  29. package/dist/components/Application.d.ts +16 -1
  30. package/dist/components/Application.js +210 -38
  31. package/dist/components/Application.js.map +1 -1
  32. package/dist/components/ApplicationScope.d.ts +7 -0
  33. package/dist/components/ApplicationScope.js +22 -0
  34. package/dist/components/ApplicationScope.js.map +1 -1
  35. package/dist/components/EntryHandler.d.ts +4 -4
  36. package/dist/components/EntryHandler.js +386 -95
  37. package/dist/components/EntryHandler.js.map +1 -1
  38. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  39. package/dist/components/RuntimeModuleTracker.js +189 -0
  40. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  41. package/dist/components/Scope.d.ts +1 -0
  42. package/dist/components/Scope.js +69 -22
  43. package/dist/components/Scope.js.map +1 -1
  44. package/dist/components/componentLoader.js +69 -10
  45. package/dist/components/componentLoader.js.map +1 -1
  46. package/dist/components/deployLifecycle.d.ts +6 -2
  47. package/dist/components/deployLifecycle.js +109 -31
  48. package/dist/components/deployLifecycle.js.map +1 -1
  49. package/dist/components/deploymentRecorder.d.ts +6 -0
  50. package/dist/components/deploymentRecorder.js +94 -12
  51. package/dist/components/deploymentRecorder.js.map +1 -1
  52. package/dist/components/mcp/tools/operations.js +6 -0
  53. package/dist/components/mcp/tools/operations.js.map +1 -1
  54. package/dist/components/operations.js +8 -6
  55. package/dist/components/operations.js.map +1 -1
  56. package/dist/config/configUtils.d.ts +6 -0
  57. package/dist/config/configUtils.js +78 -18
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/harperConfigEnvVars.js +108 -16
  60. package/dist/config/harperConfigEnvVars.js.map +1 -1
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  62. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  63. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  64. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  65. package/dist/dataLayer/hdbInfoController.js +4 -0
  66. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  67. package/dist/dataLayer/schemaDescribe.js +2 -1
  68. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  69. package/dist/resources/DatabaseTransaction.d.ts +57 -0
  70. package/dist/resources/DatabaseTransaction.js +284 -104
  71. package/dist/resources/DatabaseTransaction.js.map +1 -1
  72. package/dist/resources/ResourceInterface.d.ts +8 -2
  73. package/dist/resources/ResourceInterface.js.map +1 -1
  74. package/dist/resources/Resources.js +22 -4
  75. package/dist/resources/Resources.js.map +1 -1
  76. package/dist/resources/Table.d.ts +8 -6
  77. package/dist/resources/Table.js +158 -31
  78. package/dist/resources/Table.js.map +1 -1
  79. package/dist/resources/analytics/write.js +6 -6
  80. package/dist/resources/analytics/write.js.map +1 -1
  81. package/dist/resources/auditStore.js +27 -1
  82. package/dist/resources/auditStore.js.map +1 -1
  83. package/dist/resources/blob.d.ts +0 -1
  84. package/dist/resources/blob.js +15 -7
  85. package/dist/resources/blob.js.map +1 -1
  86. package/dist/resources/databases.d.ts +22 -5
  87. package/dist/resources/databases.js +118 -9
  88. package/dist/resources/databases.js.map +1 -1
  89. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  90. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  91. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  92. package/dist/resources/jsResource.d.ts +4 -26
  93. package/dist/resources/jsResource.js +5 -59
  94. package/dist/resources/jsResource.js.map +1 -1
  95. package/dist/resources/models/Models.d.ts +11 -1
  96. package/dist/resources/models/Models.js +10 -1
  97. package/dist/resources/models/Models.js.map +1 -1
  98. package/dist/resources/models/backendRegistry.d.ts +9 -0
  99. package/dist/resources/models/backendRegistry.js +10 -0
  100. package/dist/resources/models/backendRegistry.js.map +1 -1
  101. package/dist/resources/models/openaiStream.d.ts +16 -1
  102. package/dist/resources/models/openaiStream.js +113 -21
  103. package/dist/resources/models/openaiStream.js.map +1 -1
  104. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  105. package/dist/resources/models/v1/chatCompletions.js +115 -0
  106. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  107. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  108. package/dist/resources/models/v1/embeddings.js +71 -0
  109. package/dist/resources/models/v1/embeddings.js.map +1 -0
  110. package/dist/resources/models/v1/errors.d.ts +54 -0
  111. package/dist/resources/models/v1/errors.js +130 -0
  112. package/dist/resources/models/v1/errors.js.map +1 -0
  113. package/dist/resources/models/v1/index.d.ts +36 -0
  114. package/dist/resources/models/v1/index.js +75 -0
  115. package/dist/resources/models/v1/index.js.map +1 -0
  116. package/dist/resources/models/v1/models.d.ts +26 -0
  117. package/dist/resources/models/v1/models.js +44 -0
  118. package/dist/resources/models/v1/models.js.map +1 -0
  119. package/dist/resources/models/v1/translation.d.ts +133 -0
  120. package/dist/resources/models/v1/translation.js +298 -0
  121. package/dist/resources/models/v1/translation.js.map +1 -0
  122. package/dist/resources/roles.d.ts +1 -1
  123. package/dist/resources/roles.js +54 -7
  124. package/dist/resources/roles.js.map +1 -1
  125. package/dist/resources/search.d.ts +1 -1
  126. package/dist/resources/search.js +7 -3
  127. package/dist/resources/search.js.map +1 -1
  128. package/dist/security/jsLoader.js +84 -33
  129. package/dist/security/jsLoader.js.map +1 -1
  130. package/dist/security/role.js +4 -0
  131. package/dist/security/role.js.map +1 -1
  132. package/dist/security/superUserGuard.d.ts +7 -0
  133. package/dist/security/superUserGuard.js +23 -0
  134. package/dist/security/superUserGuard.js.map +1 -0
  135. package/dist/security/tokenAuthentication.d.ts +0 -1
  136. package/dist/security/tokenAuthentication.js +6 -2
  137. package/dist/security/tokenAuthentication.js.map +1 -1
  138. package/dist/security/user.d.ts +6 -1
  139. package/dist/security/user.js +23 -1
  140. package/dist/security/user.js.map +1 -1
  141. package/dist/server/http.d.ts +23 -2
  142. package/dist/server/http.js +99 -12
  143. package/dist/server/http.js.map +1 -1
  144. package/dist/server/operationsServer.d.ts +0 -2
  145. package/dist/server/operationsServer.js.map +1 -1
  146. package/dist/server/serverHelpers/multipartParser.js +9 -0
  147. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  148. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  149. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  150. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  151. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  152. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  153. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  154. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  155. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  156. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  157. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  158. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  159. package/dist/server/static.js +57 -18
  160. package/dist/server/static.js.map +1 -1
  161. package/dist/server/storageReclamation.d.ts +17 -0
  162. package/dist/server/storageReclamation.js +90 -6
  163. package/dist/server/storageReclamation.js.map +1 -1
  164. package/dist/server/threads/manageThreads.js +8 -0
  165. package/dist/server/threads/manageThreads.js.map +1 -1
  166. package/dist/server/threads/socketRouter.js +20 -0
  167. package/dist/server/threads/socketRouter.js.map +1 -1
  168. package/dist/server/threads/threadServer.js +12 -0
  169. package/dist/server/threads/threadServer.js.map +1 -1
  170. package/dist/sqlEngine/diff/differential.js +7 -3
  171. package/dist/sqlEngine/diff/differential.js.map +1 -1
  172. package/dist/sqlEngine/parser/normalizer.js +37 -9
  173. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  174. package/dist/sqlTranslator/index.js +6 -1
  175. package/dist/sqlTranslator/index.js.map +1 -1
  176. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  177. package/dist/upgrade/upgradePrompt.js +22 -3
  178. package/dist/upgrade/upgradePrompt.js.map +1 -1
  179. package/dist/utility/environment/environmentManager.d.ts +11 -0
  180. package/dist/utility/environment/environmentManager.js +154 -0
  181. package/dist/utility/environment/environmentManager.js.map +1 -1
  182. package/dist/utility/errors/commonErrors.d.ts +1 -0
  183. package/dist/utility/errors/commonErrors.js +1 -0
  184. package/dist/utility/errors/commonErrors.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/install/installer.js +32 -0
  189. package/dist/utility/install/installer.js.map +1 -1
  190. package/dist/utility/lmdb/environmentUtility.js +2 -1
  191. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  192. package/dist/utility/logging/harper_logger.d.ts +7 -0
  193. package/dist/utility/logging/harper_logger.js +57 -24
  194. package/dist/utility/logging/harper_logger.js.map +1 -1
  195. package/npm-shrinkwrap.json +208 -198
  196. package/package.json +10 -8
  197. package/resources/DESIGN.md +1 -0
  198. package/resources/DatabaseTransaction.ts +313 -102
  199. package/resources/ResourceInterface.ts +8 -2
  200. package/resources/Resources.ts +22 -4
  201. package/resources/Table.ts +398 -259
  202. package/resources/analytics/write.ts +22 -20
  203. package/resources/auditStore.ts +32 -1
  204. package/resources/blob.ts +15 -8
  205. package/resources/databases.ts +123 -12
  206. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  207. package/resources/jsResource.ts +5 -62
  208. package/resources/models/Models.ts +14 -1
  209. package/resources/models/backendRegistry.ts +10 -0
  210. package/resources/models/openaiStream.ts +131 -19
  211. package/resources/models/v1/chatCompletions.ts +128 -0
  212. package/resources/models/v1/embeddings.ts +70 -0
  213. package/resources/models/v1/errors.ts +141 -0
  214. package/resources/models/v1/index.ts +72 -0
  215. package/resources/models/v1/models.ts +53 -0
  216. package/resources/models/v1/translation.ts +362 -0
  217. package/resources/roles.ts +67 -7
  218. package/resources/search.ts +8 -3
  219. package/security/jsLoader.ts +84 -30
  220. package/security/role.ts +7 -0
  221. package/security/superUserGuard.ts +20 -0
  222. package/security/tokenAuthentication.ts +6 -3
  223. package/security/user.ts +26 -1
  224. package/server/DESIGN.md +45 -34
  225. package/server/http.ts +100 -13
  226. package/server/operationsServer.ts +0 -2
  227. package/server/serverHelpers/multipartParser.ts +9 -0
  228. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  229. package/server/serverHelpers/registeredOperations.ts +19 -15
  230. package/server/serverHelpers/serverHandlers.js +20 -3
  231. package/server/serverHelpers/serverUtilities.ts +10 -7
  232. package/server/static.ts +75 -20
  233. package/server/storageReclamation.ts +104 -8
  234. package/server/threads/manageThreads.js +8 -0
  235. package/server/threads/socketRouter.ts +20 -0
  236. package/server/threads/threadServer.js +11 -0
  237. package/sqlTranslator/index.ts +6 -1
  238. package/static/defaultConfig.yaml +2 -0
  239. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
  240. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
  241. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
  242. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  243. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
  244. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  245. package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
  246. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  247. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  248. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  249. package/studio/web/assets/index-CDeqVx71.css +1 -0
  250. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  251. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  252. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  253. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  254. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  255. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  256. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  257. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  258. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
  259. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
  260. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  261. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
  262. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  263. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  264. package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
  265. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
  266. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  267. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  268. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  269. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  270. package/studio/web/assets/register-BDy01-q3.js +3 -0
  271. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  272. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  273. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  274. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
  275. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  276. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  277. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
  278. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  279. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  280. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  281. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  282. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  283. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  284. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
  285. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  286. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  287. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  288. package/studio/web/index.html +6 -7
  289. package/upgrade/upgradePrompt.ts +22 -3
  290. package/utility/environment/environmentManager.ts +152 -0
  291. package/utility/errors/commonErrors.ts +2 -0
  292. package/utility/hdbTerms.ts +2 -0
  293. package/utility/install/installer.ts +37 -0
  294. package/utility/lmdb/environmentUtility.ts +2 -1
  295. package/utility/logging/harper_logger.ts +57 -26
  296. package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
  297. package/studio/web/assets/index-Bh_CNAHr.js +0 -809
  298. package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
  299. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  300. package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
  301. package/studio/web/assets/notifications-CwKhipK7.js +0 -2
  302. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  303. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  304. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  305. package/studio/web/assets/setup-B56Oz1_u.js +0 -3
  306. package/studio/web/assets/status-BAod7p3o.js +0 -62
  307. package/studio/web/assets/status-BAod7p3o.js.map +0 -1
  308. package/studio/web/assets/textarea-DhLMZ3PA.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
  }
@@ -219,6 +281,7 @@ class DatabaseTransaction {
219
281
  readTxnRefCount;
220
282
  readTxnsUsed;
221
283
  timeout;
284
+ timeoutBudget = 0;
222
285
  validated = 0;
223
286
  timestamp = 0;
224
287
  retries = 0;
@@ -227,7 +290,18 @@ class DatabaseTransaction {
227
290
  replicatedConfirmation;
228
291
  getReadTxn(disableSnapshot) {
229
292
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
230
- this.timeout = txnExpiration; // reset the timeout
293
+ // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so
294
+ // while no uncommitted writes are held: staged writes hold write intents that other writers'
295
+ // coordinated-retry commits park on, so a handler that wrote once and then only reads — an
296
+ // orphaned long-poll whose client had already gone, in harper#2001 — must not keep those
297
+ // intents alive by reading. A transaction that keeps writing stays alive; so does a purely
298
+ // read-only one. A committed transaction re-arms too: its intents went with the commit, and
299
+ // the monitor bounds its retained read snapshot separately.
300
+ // `writes`/`next` are checked inline first: this is a hot path and the dominant case is a
301
+ // single-store transaction that has never written.
302
+ if ((this.writes.length === 0 && !this.next) || this.open !== exports.TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
303
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget);
304
+ }
231
305
  if (this.transaction) {
232
306
  if (this.transaction.openTimer)
233
307
  this.transaction.openTimer = 0;
@@ -275,6 +349,7 @@ class DatabaseTransaction {
275
349
  trackedTxns.delete(this);
276
350
  this.transaction?.abort();
277
351
  this.transaction = null;
352
+ this.completeDeferredContextRelease();
278
353
  }
279
354
  }
280
355
  /**
@@ -293,6 +368,20 @@ class DatabaseTransaction {
293
368
  harperLogger.debug?.('releasing timed-out read transaction', error);
294
369
  }
295
370
  this.transaction = null;
371
+ this.completeDeferredContextRelease();
372
+ }
373
+ /**
374
+ * Complete a context release that releaseContext() deferred because outstanding read iterators
375
+ * were still using this transaction (see releaseContext()) — called once the last one drains,
376
+ * whether that happens naturally (doneReadTxn()) or is forced by the long-transaction monitor
377
+ * (releaseReadTxn()).
378
+ */
379
+ completeDeferredContextRelease() {
380
+ if (!this.pendingContextRelease)
381
+ return;
382
+ this.pendingContextRelease = false;
383
+ if (this.#context?.transaction === this)
384
+ this.#context.transaction = null;
296
385
  }
297
386
  disregardReadTxn() {
298
387
  if (--this.readTxnRefCount === 0 && this.readTxnsUsed === 1) {
@@ -324,22 +413,69 @@ class DatabaseTransaction {
324
413
  this.writes = [];
325
414
  this.writesByKey = undefined;
326
415
  }
416
+ /**
417
+ * Drop this transaction's back-reference from its context once completed (commit or abort),
418
+ * so a long-lived context (e.g. an MQTT subscription context held open for the life of a
419
+ * suspended delivery loop) doesn't keep pinning a finished transaction in memory. Guarded by
420
+ * identity: a context already re-pointed at a different (e.g. reused) transaction is untouched.
421
+ *
422
+ * `final` must be false for an in-callback explicit `context.transaction.commit()` — the
423
+ * "commit in the middle" pattern intentionally keeps recommitting and adding writes to the
424
+ * SAME instance (see the comment above about a transaction being "reused and committed
425
+ * again"), so releasing here would strand those later writes with no transaction to join.
426
+ * Only resources/transaction.ts's own wrapper commit (`{ doneWriting: true }`, once the
427
+ * caller's callback has fully returned) and abort() are truly final.
428
+ *
429
+ * A final commit can still have outstanding read iterators streaming through this.transaction
430
+ * (see the outstanding-iterators branch in commit()) — those keep this instance meaningfully
431
+ * alive (a fresh write on the same context must not join a DIFFERENT, already-replayed
432
+ * transaction) until doneReadTxn() drains the last one, so the release is deferred to there.
433
+ *
434
+ * Sets `.transaction` to `null` rather than deleting the property: `Context.transaction` is
435
+ * typed `DatabaseTransaction | null | undefined` precisely to document this released state, and
436
+ * `delete` would repeatedly force a long-lived, hot context (e.g. an MQTT subscription context
437
+ * releasing/reattaching a transaction per message) into V8's slower dictionary-mode property
438
+ * storage.
439
+ */
440
+ releaseContext(final) {
441
+ if (!final)
442
+ return;
443
+ if (this.readTxnsUsed > 0) {
444
+ this.pendingContextRelease = true;
445
+ return;
446
+ }
447
+ if (this.#context?.transaction === this)
448
+ this.#context.transaction = null;
449
+ }
327
450
  checkOverloaded() {
328
- if (outstandingCommit &&
451
+ if (oldestOutstandingCommit &&
329
452
  !this.overloadChecked &&
330
- performance.now() - outstandingCommitStart > MAX_OUTSTANDING_TXN_DURATION) {
331
- if (!outstandingCommitLogged) {
453
+ performance.now() - oldestOutstandingCommit.start > MAX_OUTSTANDING_TXN_DURATION) {
454
+ const now = performance.now();
455
+ // Also rate-limited across the whole overload episode (not just deduped per commit): under
456
+ // sustained heavy load, many distinct commits can each individually age past the limit in
457
+ // quick succession as earlier ones finally settle, which without this cap would turn one
458
+ // overload episode into a growing stream of ERROR lines — the same "flood" harper#2001's
459
+ // original per-request log was fixed to avoid, just shifted from per-request to per-commit.
460
+ // A commit skipped by the cooldown is NOT marked `logged`, so it still gets a log later if
461
+ // it's still the oldest once the cooldown clears, rather than going silent forever.
462
+ if (!oldestOutstandingCommit.logged && now - lastOverloadLogAt > OVERLOAD_LOG_MIN_INTERVAL_MS) {
332
463
  // Log once per stuck commit (not once per rejected request, harper#2001): a wedged
333
464
  // 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;
465
+ // 503, which was the single biggest obstacle to root-causing a recurrence. The flag lives
466
+ // on the node itself, so if THIS commit settles while still over the limit and a
467
+ // different one is now oldest, that one logs too instead of staying silent forever.
468
+ oldestOutstandingCommit.logged = true;
469
+ lastOverloadLogAt = now;
470
+ const nativeTransactionId = oldestOutstandingCommit.nativeTransaction?.id;
471
+ const store = oldestOutstandingCommit.store;
472
+ const startedFrom = oldestOutstandingCommit.startedFrom;
337
473
  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 ?? '?'}` +
474
+ `${Math.round(now - oldestOutstandingCommit.start)}ms (exceeds the ` +
475
+ `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${store?.rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
340
476
  (nativeTransactionId !== undefined ? ` (transaction ${nativeTransactionId})` : '') +
341
- (outstandingCommitStartedFrom?.resourceName
342
- ? `, started from ${outstandingCommitStartedFrom.resourceName}${outstandingCommitStartedFrom.method ? '.' + outstandingCommitStartedFrom.method : ''}`
477
+ (startedFrom?.resourceName
478
+ ? `, started from ${startedFrom.resourceName}${startedFrom.method ? '.' + startedFrom.method : ''}`
343
479
  : '') +
344
480
  `. Further record updates and publishes from new application requests on this thread ` +
345
481
  `will be rejected with 503 until the commit settles or the process is restarted (deletes, ` +
@@ -352,6 +488,13 @@ class DatabaseTransaction {
352
488
  addWrite(operation) {
353
489
  if (this.timedOut)
354
490
  throw transactionOpenTooLongError();
491
+ // A write is activity: it re-arms the idle limit on this link even though the reads it
492
+ // performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive
493
+ // and only an idle one holding write intents is reaped.
494
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget ?? 0);
495
+ // Independent write-recency signal for chainStillActive (see the field comment) — reads never
496
+ // touch this, only writes do.
497
+ this.writeTimeout = this.timeout;
355
498
  this.linkWrite(operation);
356
499
  this.writes.push(operation);
357
500
  if (!operation.deferSave) {
@@ -481,6 +624,12 @@ class DatabaseTransaction {
481
624
  this.writes = this.writes.filter((write) => write); // filter out removed entries
482
625
  if (this.writes.length > 0) {
483
626
  if (!options.transaction) {
627
+ if (!replayedWritesWarned) {
628
+ replayedWritesWarned = true;
629
+ 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` +
630
+ (this.startedFrom ? `, from ${this.startedFrom.resourceName}.${this.startedFrom.method}` : '') +
631
+ `. Fully consume (or close) iterators before committing to avoid this. Logged once per process.`);
632
+ }
484
633
  // Deliberately NOT marked isRetry and NOT carrying over the original's onCommit:
485
634
  // audit/txn-log entries batch natively on the transaction they were staged into and
486
635
  // are only durably written by that transaction's commit attempt (an abort discards
@@ -501,6 +650,29 @@ class DatabaseTransaction {
501
650
  // re-staged the writes into it
502
651
  commitResolution = transaction.commit();
503
652
  recordCommitLatency(commitResolution, performance.now());
653
+ // Write-queue-depth accounting for this replay commit happens uniformly below, via
654
+ // trackOutstandingCommit(commitResolution) — see that function's comment. Omitting
655
+ // dedicated accounting here (as a prior version of this replay path did) used to leave
656
+ // write-transaction-queue-depth, the one metric that can observe a commit that never
657
+ // settles (harper#2001), reading zero for exactly this path.
658
+ }
659
+ // No commit will ever run on the retained handle — the replay above owns these
660
+ // writes — so this is the only place its write intents can be released. Left in
661
+ // place, other writers' coordinated-retry commits park on them until the last
662
+ // iterator finishes (harper#2001). Reads through the handle, including
663
+ // read-your-own-writes, keep working. Once only: a coordinated-retry or backoff
664
+ // round re-enters this branch on the same retained handle. Fenced like the other
665
+ // post-submit steps here: the replay commit is already in flight, so a throw must
666
+ // not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7
667
+ // lacks the method.
668
+ if (!this.writesAbandoned) {
669
+ this.writesAbandoned = true;
670
+ try {
671
+ this.transaction?.abandonWrites?.();
672
+ }
673
+ catch (error) {
674
+ harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
675
+ }
504
676
  }
505
677
  }
506
678
  else {
@@ -516,31 +688,19 @@ class DatabaseTransaction {
516
688
  // Promise<number | void>; it is handled in the resolve callback below.
517
689
  commitResolution = transaction.commit();
518
690
  // 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
691
+ // metric. This is the same clock the overload check uses (trackOutstandingCommit
692
+ // stamps each attempt at submit), so a rising p99/p999 is the leading indicator for the
521
693
  // "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.
694
+ // conflict retry rejects this promise and issues a fresh commit(), which is tracked as
695
+ // its own attempt, so recording per attempt matches the overload semantics.
526
696
  // commitResolution's declared type (Promise<number | void> | void) doesn't narrow to
527
697
  // Promise<void> here because the widening union defeats flow analysis on the prior
528
698
  // cast assignment; re-assert it — this branch's commit() result is always a Promise.
529
699
  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
- }
700
+ // Write-queue-depth accounting for this commit happens uniformly below, via
701
+ // trackOutstandingCommit(commitResolution) see that function's comment. A
702
+ // transient-conflict retry rejects this promise and issues a fresh commit()
703
+ // (re-entering here), which trackOutstandingCommit tracks as its own attempt.
544
704
  }
545
705
  else {
546
706
  try {
@@ -556,43 +716,10 @@ class DatabaseTransaction {
556
716
  }
557
717
  }
558
718
  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
- }
719
+ // Read the table off the write itself, not this.db, which is whichever table first
720
+ // claimed this per-database transaction in txnForContext and so can name the wrong
721
+ // table when a transaction spans more than one table in the same database.
722
+ trackOutstandingCommit(commitResolution, this.writes[0]?.store, this.startedFrom, transaction);
596
723
  const completions = [];
597
724
  return commitResolution.then((commitResult) => {
598
725
  if (commitResult === RETRY_NOW_VALUE) {
@@ -656,6 +783,7 @@ class DatabaseTransaction {
656
783
  // now reset transactions tracking; this transaction be reused and committed again
657
784
  this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
658
785
  this.clearWrites();
786
+ this.releaseContext(!!options.doneWriting);
659
787
  this.next = null;
660
788
  let txnTime = this.timestamp;
661
789
  this.timestamp = 0; // reset the timestamp as well
@@ -732,6 +860,11 @@ class DatabaseTransaction {
732
860
  catch (abortError) {
733
861
  harperLogger.debug?.('aborting transaction after failed commit', abortError);
734
862
  }
863
+ // A terminal failure is just as final as a success — release the context's
864
+ // back-reference here too, or transaction.ts's onComplete() (which has no
865
+ // rejection handler of its own) would leave a long-lived context pinning this
866
+ // CLOSED wrapper forever.
867
+ this.releaseContext(!!options.doneWriting);
735
868
  throw error;
736
869
  }
737
870
  });
@@ -741,6 +874,7 @@ class DatabaseTransaction {
741
874
  (0, blob_ts_1.cleanupUnusedBlobs)(write.savedBlobs, (0, blob_ts_1.collectRetainedFileIds)(write.store.getEntry(write.key)?.value));
742
875
  }
743
876
  this.clearWrites();
877
+ this.releaseContext(!!options.doneWriting);
744
878
  const txnResolution = {
745
879
  txnTime: this.timestamp,
746
880
  };
@@ -772,6 +906,13 @@ class DatabaseTransaction {
772
906
  }
773
907
  // reset the transaction
774
908
  this.clearWrites();
909
+ // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
910
+ // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
911
+ // starting a fresh one) so the rest of the logical operation fails atomically via the
912
+ // poison check in addWrite()/commit(), rather than silently landing a later write on a
913
+ // brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
914
+ // make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
915
+ this.releaseContext(!this.timedOut);
775
916
  }
776
917
  /**
777
918
  * Give up on a chain of linked transactions after exhausting conflict retries: poison every link
@@ -893,9 +1034,33 @@ class ImmediateTransaction extends DatabaseTransaction {
893
1034
  }
894
1035
  exports.ImmediateTransaction = ImmediateTransaction;
895
1036
  let timer;
1037
+ /**
1038
+ * True when a link other than `txn` in the same multi-store chain was written recently enough to
1039
+ * still be active — i.e. its `writeTimeout` (set only by addWrite, see the field comment) hasn't
1040
+ * decayed to zero. Writes re-arm only the link that receives them, so a chain writing database B
1041
+ * while its head only reads A would otherwise be aborted by the head's own decay.
1042
+ */
1043
+ function chainStillActive(txn) {
1044
+ for (let link = txn.next; link; link = link.next) {
1045
+ // A write-only link (e.g. a blind write to a second database, never itself read) never calls
1046
+ // getReadTxn, so it's never added to trackedTxns and the main loop below never decays it.
1047
+ // Decay it here instead, so an idle write-only link eventually expires rather than keeping the
1048
+ // whole chain immortal (harper#2001's blind-write shape).
1049
+ if (!trackedTxns.has(link) && link.writeTimeout > 0)
1050
+ link.writeTimeout -= txnExpiration;
1051
+ if (link.writeTimeout > 0)
1052
+ return true;
1053
+ }
1054
+ return false;
1055
+ }
896
1056
  function startMonitoringTxns() {
897
1057
  timer = setInterval(function () {
898
1058
  for (const txn of trackedTxns) {
1059
+ // Decay write recency once per tick for every tracked link, independent of the `timeout`
1060
+ // branches below — a tracked link that keeps its own idle limit alive by reading must not
1061
+ // thereby keep chainStillActive believing it was written recently too.
1062
+ if (txn.writeTimeout > 0)
1063
+ txn.writeTimeout -= txnExpiration;
899
1064
  if (txn.timeout <= 0) {
900
1065
  const url = txn.getContext()?.url;
901
1066
  if (txn.open === exports.TRANSACTION_STATE.CLOSED) {
@@ -908,6 +1073,13 @@ function startMonitoringTxns() {
908
1073
  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 : '')}`);
909
1074
  txn.releaseReadTxn();
910
1075
  }
1076
+ else if (txn.hasPendingWrites() && chainStillActive(txn)) {
1077
+ // A later link in the chain was written recently (writes re-arm only the link that
1078
+ // receives them, and a multi-store transaction can be writing database B while this
1079
+ // head only reads A). The logical transaction is still active, so re-arm this link
1080
+ // rather than aborting the whole chain out from under it.
1081
+ txn.timeout = Math.max(txnExpiration, txn.timeoutBudget ?? 0);
1082
+ }
911
1083
  else if (txn.hasPendingWrites() && !txn.sourceApply && !txn.isReplay) {
912
1084
  // Abort and surface an error rather than force-committing a partial write set: silently
913
1085
  // committing on the application's behalf breaks atomicity and can leave orphaned
@@ -940,7 +1112,7 @@ function startMonitoringTxns() {
940
1112
  catch (error) {
941
1113
  harperLogger.debug?.(`Error committing timed out transaction: ${error.message}`);
942
1114
  }
943
- txn.timeout = txnExpiration;
1115
+ txn.timeout = Math.max(txnExpiration, txn.timeoutBudget ?? 0);
944
1116
  }
945
1117
  }
946
1118
  else {
@@ -950,6 +1122,14 @@ function startMonitoringTxns() {
950
1122
  }, txnExpiration).unref();
951
1123
  }
952
1124
  startMonitoringTxns();
1125
+ /**
1126
+ * Test seam: re-arms the once-per-process replay warning. The whole unit suite shares one process,
1127
+ * so whichever test first drives a commit under open iterators consumes the warning for every test
1128
+ * after it.
1129
+ */
1130
+ function resetReplayedWritesWarning() {
1131
+ replayedWritesWarned = false;
1132
+ }
953
1133
  function setTxnExpiration(ms) {
954
1134
  clearInterval(timer);
955
1135
  txnExpiration = ms;