@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
@@ -31,22 +31,112 @@ const MAX_RETRIES = 40;
31
31
  // Cap the per-retry backoff so replication-applied transactions, which retry conflicts without a
32
32
  // cap (see the commit rejection handler), don't grow the delay unbounded.
33
33
  const MAX_RETRY_DELAY_MS = 1000;
34
- let outstandingCommit, outstandingCommitStart;
35
- // Identity references for the commit that armed `outstandingCommit`, kept for the one-time
36
- // checkOverloaded() log below (harper#2001) — `outstandingCommit` itself is otherwise anonymous, so
37
- // a stuck commit gives no indication of which database/table/resource to investigate. Snapshotted
38
- // at arm time (not read from `this.writes`/`this.startedFrom` lazily off the DatabaseTransaction
39
- // object) because that object can be reused for a later immediate commit while the original native
40
- // commit is still wedged — its resolve handler runs clearWrites() on the SAME object, which would
41
- // blank or replace the identity out from under a deferred read. Three reference assignments, no
42
- // allocation, and no lingering reference to the writes/entries graph (the native transaction handle
43
- // is retained, but its lifetime is already bounded by the pending outstandingCommit promise).
44
- let outstandingCommitStore: any;
45
- let outstandingCommitStartedFrom: { resourceName: string; method: string } | undefined;
46
- let outstandingCommitNativeTransaction: any;
47
- // Ensures the checkOverloaded() rejection is logged once per stuck commit, not once per rejected
48
- // requestunder load a wedged thread can reject hundreds of requests per second.
49
- let outstandingCommitLogged = false;
34
+ // Every native write commit currently outstanding on this worker thread, oldest first.
35
+ // checkOverloaded() sheds new application writes once the OLDEST of these has been outstanding
36
+ // past MAX_OUTSTANDING_TXN_DURATION, so a commit whose promise never settles (harper#2001) is
37
+ // detected whichever transaction submitted it. A linked list rather than a Set because the write
38
+ // path reads the oldest on every write and must not allocate an iterator to do it, and because
39
+ // commits settle out of order and so have to unlink in constant time.
40
+ interface OutstandingCommit {
41
+ start: number;
42
+ prev: OutstandingCommit | undefined;
43
+ next: OutstandingCommit | undefined;
44
+ // Identity for the one-time checkOverloaded() log below (harper#2001) — otherwise a stuck commit
45
+ // gives no indication of which database/table/resource to investigate. Snapshotted at arm time
46
+ // (not read from `this.writes`/`this.startedFrom` lazily off the DatabaseTransaction object)
47
+ // because that object can be reused for a later immediate commit while this native commit is
48
+ // still wedged its resolve handler runs clearWrites() on the SAME object, which would blank or
49
+ // replace the identity out from under a deferred read. Per-node (not a single module-level slot)
50
+ // so that every outstanding commit carries its own identity and its own `logged` flag: a second
51
+ // commit that is still stuck once the first settles becomes the new oldest and logs on its own.
52
+ store: any;
53
+ startedFrom: { resourceName: string; method: string } | undefined;
54
+ nativeTransaction: any;
55
+ logged: boolean;
56
+ }
57
+ let oldestOutstandingCommit: OutstandingCommit | undefined;
58
+ let newestOutstandingCommit: OutstandingCommit | undefined;
59
+ let outstandingCommitCount = 0;
60
+ // Caps the stuck-commit log (checkOverloaded() below) to at most one line per this interval across
61
+ // the whole thread, regardless of how many distinct commits individually cross the threshold — see
62
+ // the comment at the log site for why a per-commit-only dedup isn't enough under sustained overload.
63
+ const OVERLOAD_LOG_MIN_INTERVAL_MS = 1000;
64
+ let lastOverloadLogAt = -Infinity;
65
+
66
+ // Track a submitted commit until it settles. Every attempt is tracked unconditionally: a
67
+ // coordinated retry round and a chained second-store commit are both issued from inside the
68
+ // preceding commit's own resolve handler, which runs before any reaction that could release a
69
+ // single shared slot — so anything conditional on "is something already outstanding" skips them
70
+ // and leaves a wedged retry or chain invisible to checkOverloaded() forever. Each attempt is timed
71
+ // from its own submission, keeping the overload window per-attempt rather than cumulative over a
72
+ // retry ladder. `.then(untrack, untrack)` also marks an ERR_BUSY rejection handled, so this
73
+ // tracking never surfaces as an unhandled rejection alongside the caller's own handler.
74
+ // Exported only so unit tests can drive the list with controllable promises: the unlink order that
75
+ // matters (a middle or tail node settling first) cannot be forced through real writes, and a node
76
+ // left linked would 503 every write on this thread forever. commit() below is the sole caller.
77
+ // Also the single source for write-queue-depth accounting (getTransactionQueueDepths below): every
78
+ // native commit this function tracks is, by definition, exactly the write-queue backlog — see the
79
+ // comment there for why that used to be a second, separately-maintained counter.
80
+ // `store`/`startedFrom`/`nativeTransaction` are the identity snapshot for checkOverloaded()'s stuck-
81
+ // commit log (harper#2001); omit them (as the test seam below does) when a caller has none to give.
82
+ export function trackOutstandingCommit(
83
+ commitResolution: Promise<number | void>,
84
+ store?: any,
85
+ startedFrom?: { resourceName: string; method: string },
86
+ nativeTransaction?: any
87
+ ): void {
88
+ // Guards against a future caller passing a non-Promise: today commit() always hands this a real
89
+ // Promise, but an unguarded link here would leave a node permanently wedged in the list (503ing
90
+ // every write on this thread) with no settlement to ever unlink it.
91
+ if (typeof commitResolution?.then !== 'function') return;
92
+ const outstanding: OutstandingCommit = {
93
+ start: performance.now(),
94
+ prev: newestOutstandingCommit,
95
+ next: undefined,
96
+ store,
97
+ startedFrom,
98
+ nativeTransaction,
99
+ logged: false,
100
+ };
101
+ if (newestOutstandingCommit != null) newestOutstandingCommit.next = outstanding;
102
+ else oldestOutstandingCommit = outstanding;
103
+ newestOutstandingCommit = outstanding;
104
+ outstandingCommitCount++;
105
+ // Doubles as the write-queue-depth high-water mark (see getTransactionQueueDepths): every
106
+ // outstanding commit is a write-queue entry, so the peak of one is the peak of the other.
107
+ if (outstandingCommitCount > writeTxnQueueDepthHighWater) writeTxnQueueDepthHighWater = outstandingCommitCount;
108
+ // Guards against double-untracking the same node: `.then(untrack, untrack)` below means a promise
109
+ // that both resolves and later has its rejection handler independently triggered (or is tracked via
110
+ // a shared/misused resolution) could otherwise run the unlink twice, corrupting the list or driving
111
+ // outstandingCommitCount negative.
112
+ let untracked = false;
113
+ const untrack = () => {
114
+ if (untracked) return;
115
+ untracked = true;
116
+ if (outstanding.prev != null) outstanding.prev.next = outstanding.next;
117
+ else oldestOutstandingCommit = outstanding.next;
118
+ if (outstanding.next != null) outstanding.next.prev = outstanding.prev;
119
+ else newestOutstandingCommit = outstanding.prev;
120
+ outstandingCommitCount--;
121
+ };
122
+ commitResolution.then(untrack, untrack);
123
+ }
124
+
125
+ /**
126
+ * How many write commits are outstanding on this thread and how long the oldest has been waiting
127
+ * (`oldestAgeMs` is undefined when none is). `oldestAgeMs` is exactly the value checkOverloaded()
128
+ * rejects on, exposed so a commit that never settles (harper#2001) can be observed directly rather
129
+ * than inferred from the 503s it eventually produces.
130
+ */
131
+ export function getOutstandingCommits(): { count: number; oldestAgeMs: number | undefined } {
132
+ return {
133
+ count: outstandingCommitCount,
134
+ oldestAgeMs: oldestOutstandingCommit ? performance.now() - oldestOutstandingCommit.start : undefined,
135
+ };
136
+ }
137
+ // Once per process: committing under open read iterators forces a write replay, so the warning is
138
+ // about the caller's pattern, not the individual commit.
139
+ let replayedWritesWarned = false;
50
140
 
51
141
  // The analytics module registers a recorder here at load (dependency inversion, mirroring
52
142
  // `replicationConfirmation` below) so the storage layer doesn't statically import the analytics/server
@@ -80,29 +170,20 @@ function recordCommitLatency(commitResolution: Promise<number | void>, submitted
80
170
 
81
171
  // Queue-depth gauges surfaced through the analytics pipeline (write-transaction-queue-depth /
82
172
  // read-transaction-queue-depth). Per-thread state; the analytics aggregator sums across threads.
83
- // `writeTxnQueueDepth` counts write commits handed to the storage engine but not yet resolved
84
- // this is the backlog that, when it drains too slowly, produces the "Outstanding write transactions
85
- // have too long of queue" overload error. Read depth is derived from the live `trackedTxns` set
86
- // (every tracked transaction holds an open read snapshot). We also retain a high-water mark per
87
- // sampling window because the queue can fill and drain within a single (~1s) analytics period, so an
173
+ // The write depth is `outstandingCommitCount` itself (maintained above by trackOutstandingCommit)
174
+ // write commits handed to the storage engine but not yet resolved are exactly the same set of native
175
+ // commits the overload check tracks, and keeping one counter instead of two removes the duplicate
176
+ // per-commit bookkeeping (and the drift risk: a code path that updates one but not the other, as the
177
+ // replay path did before this fix). Read depth is derived from the live `trackedTxns` set (every
178
+ // tracked transaction holds an open read snapshot). We also retain a high-water mark per sampling
179
+ // window because the queue can fill and drain within a single (~1s) analytics period, so an
88
180
  // instantaneous sample taken at emit time would routinely miss the spike operators need to see.
89
181
  // RocksDB-write-path only: LMDB routes through the separate LMDBTransaction.commit()/getReadTxn()
90
182
  // overrides (resources/LMDBTransaction.ts), which maintain their own unrelated `trackedTxns` set and
91
183
  // do not call into this accounting.
92
- let writeTxnQueueDepth = 0;
93
184
  let writeTxnQueueDepthHighWater = 0;
94
185
  let readTxnQueueDepthHighWater = 0;
95
186
 
96
- function enterWriteQueue() {
97
- if (++writeTxnQueueDepth > writeTxnQueueDepthHighWater) writeTxnQueueDepthHighWater = writeTxnQueueDepth;
98
- }
99
- function leaveWriteQueue() {
100
- // Floor at zero: accounting is balanced by construction (every enterWriteQueue has exactly one
101
- // matching settlement), but the guard is cheap insurance against a future call-site imbalance
102
- // producing a negative depth that would corrupt every subsequent sample.
103
- if (writeTxnQueueDepth > 0) writeTxnQueueDepth--;
104
- }
105
-
106
187
  /**
107
188
  * Returns the current write/read transaction queue depths for this thread along with the high-water
108
189
  * mark observed since the previous call, then resets the high-water marks to the current depth so the
@@ -113,12 +194,12 @@ export function getTransactionQueueDepths() {
113
194
  // dominates the current size here — no need to reconcile against `readDepth` before reporting.
114
195
  const readDepth = trackedTxns.size;
115
196
  const depths = {
116
- writeDepth: writeTxnQueueDepth,
197
+ writeDepth: outstandingCommitCount,
117
198
  writeMaxDepth: writeTxnQueueDepthHighWater,
118
199
  readDepth,
119
200
  readMaxDepth: readTxnQueueDepthHighWater,
120
201
  };
121
- writeTxnQueueDepthHighWater = writeTxnQueueDepth;
202
+ writeTxnQueueDepthHighWater = outstandingCommitCount;
122
203
  readTxnQueueDepthHighWater = readDepth;
123
204
  return depths;
124
205
  }
@@ -248,6 +329,13 @@ export class DatabaseTransaction implements Transaction {
248
329
  readTxnRefCount: number;
249
330
  readTxnsUsed: number;
250
331
  timeout: number;
332
+ // Write recency, tracked separately from `timeout`: set only by addWrite, never by a read. `timeout`
333
+ // is re-armed by reads too (on a link with no pending writes of its own, see the fast path in
334
+ // getReadTxn), so chainStillActive can't use it to mean "this link was written recently" — a `.next`
335
+ // link with no writes that is being read in a loop would otherwise masquerade as write activity and
336
+ // keep a write-holding head immortal.
337
+ declare writeTimeout: number;
338
+ timeoutBudget = 0;
251
339
  validated = 0;
252
340
  timestamp = 0;
253
341
  retries = 0;
@@ -256,6 +344,10 @@ export class DatabaseTransaction implements Transaction {
256
344
  // Whether this read handle's base reference (readTxnsUsed starts at 1 in getReadTxn) has been
257
345
  // consumed by a commit round; iterator references are consumed only by doneReadTxn().
258
346
  declare baseReadRefConsumed?: boolean;
347
+ // Set when a final commit/abort wanted to release the context's back-reference (see
348
+ // releaseContext()) but outstanding read iterators were still using this transaction —
349
+ // doneReadTxn() completes the release once the last iterator drains.
350
+ declare pendingContextRelease?: boolean;
259
351
  declare startedFrom?: {
260
352
  resourceName: string;
261
353
  method: string;
@@ -278,10 +370,24 @@ export class DatabaseTransaction implements Transaction {
278
370
  // open-transaction limit. Once poisoned, any further addWrite/commit throws transactionOpenTooLongError
279
371
  // so the request rolls back cleanly instead of silently committing a partial write set (issue #1407).
280
372
  declare timedOut?: boolean;
373
+ // Set once the retained read handle's write intents have been released (see commit()'s
374
+ // outstanding-iterators branch), so a retry round cannot re-fire the release.
375
+ declare writesAbandoned?: boolean;
281
376
 
282
377
  getReadTxn(disableSnapshot?: boolean): ReadTransaction {
283
378
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
284
- this.timeout = txnExpiration; // reset the timeout
379
+ // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so
380
+ // while no uncommitted writes are held: staged writes hold write intents that other writers'
381
+ // coordinated-retry commits park on, so a handler that wrote once and then only reads — an
382
+ // orphaned long-poll whose client had already gone, in harper#2001 — must not keep those
383
+ // intents alive by reading. A transaction that keeps writing stays alive; so does a purely
384
+ // read-only one. A committed transaction re-arms too: its intents went with the commit, and
385
+ // the monitor bounds its retained read snapshot separately.
386
+ // `writes`/`next` are checked inline first: this is a hot path and the dominant case is a
387
+ // single-store transaction that has never written.
388
+ if ((this.writes.length === 0 && !this.next) || this.open !== TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
389
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget);
390
+ }
285
391
  if (this.transaction) {
286
392
  if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0;
287
393
  return this.transaction;
@@ -328,6 +434,7 @@ export class DatabaseTransaction implements Transaction {
328
434
  trackedTxns.delete(this);
329
435
  this.transaction?.abort();
330
436
  this.transaction = null;
437
+ this.completeDeferredContextRelease();
331
438
  }
332
439
  }
333
440
 
@@ -346,6 +453,19 @@ export class DatabaseTransaction implements Transaction {
346
453
  harperLogger.debug?.('releasing timed-out read transaction', error);
347
454
  }
348
455
  this.transaction = null;
456
+ this.completeDeferredContextRelease();
457
+ }
458
+
459
+ /**
460
+ * Complete a context release that releaseContext() deferred because outstanding read iterators
461
+ * were still using this transaction (see releaseContext()) — called once the last one drains,
462
+ * whether that happens naturally (doneReadTxn()) or is forced by the long-transaction monitor
463
+ * (releaseReadTxn()).
464
+ */
465
+ private completeDeferredContextRelease(): void {
466
+ if (!this.pendingContextRelease) return;
467
+ this.pendingContextRelease = false;
468
+ if (this.#context?.transaction === this) this.#context.transaction = null;
349
469
  }
350
470
 
351
471
  disregardReadTxn(): void {
@@ -378,25 +498,71 @@ export class DatabaseTransaction implements Transaction {
378
498
  this.writesByKey = undefined;
379
499
  }
380
500
 
501
+ /**
502
+ * Drop this transaction's back-reference from its context once completed (commit or abort),
503
+ * so a long-lived context (e.g. an MQTT subscription context held open for the life of a
504
+ * suspended delivery loop) doesn't keep pinning a finished transaction in memory. Guarded by
505
+ * identity: a context already re-pointed at a different (e.g. reused) transaction is untouched.
506
+ *
507
+ * `final` must be false for an in-callback explicit `context.transaction.commit()` — the
508
+ * "commit in the middle" pattern intentionally keeps recommitting and adding writes to the
509
+ * SAME instance (see the comment above about a transaction being "reused and committed
510
+ * again"), so releasing here would strand those later writes with no transaction to join.
511
+ * Only resources/transaction.ts's own wrapper commit (`{ doneWriting: true }`, once the
512
+ * caller's callback has fully returned) and abort() are truly final.
513
+ *
514
+ * A final commit can still have outstanding read iterators streaming through this.transaction
515
+ * (see the outstanding-iterators branch in commit()) — those keep this instance meaningfully
516
+ * alive (a fresh write on the same context must not join a DIFFERENT, already-replayed
517
+ * transaction) until doneReadTxn() drains the last one, so the release is deferred to there.
518
+ *
519
+ * Sets `.transaction` to `null` rather than deleting the property: `Context.transaction` is
520
+ * typed `DatabaseTransaction | null | undefined` precisely to document this released state, and
521
+ * `delete` would repeatedly force a long-lived, hot context (e.g. an MQTT subscription context
522
+ * releasing/reattaching a transaction per message) into V8's slower dictionary-mode property
523
+ * storage.
524
+ */
525
+ private releaseContext(final: boolean): void {
526
+ if (!final) return;
527
+ if (this.readTxnsUsed > 0) {
528
+ this.pendingContextRelease = true;
529
+ return;
530
+ }
531
+ if (this.#context?.transaction === this) this.#context.transaction = null;
532
+ }
533
+
381
534
  checkOverloaded() {
382
535
  if (
383
- outstandingCommit &&
536
+ oldestOutstandingCommit &&
384
537
  !this.overloadChecked &&
385
- performance.now() - outstandingCommitStart > MAX_OUTSTANDING_TXN_DURATION
538
+ performance.now() - oldestOutstandingCommit.start > MAX_OUTSTANDING_TXN_DURATION
386
539
  ) {
387
- if (!outstandingCommitLogged) {
540
+ const now = performance.now();
541
+ // Also rate-limited across the whole overload episode (not just deduped per commit): under
542
+ // sustained heavy load, many distinct commits can each individually age past the limit in
543
+ // quick succession as earlier ones finally settle, which without this cap would turn one
544
+ // overload episode into a growing stream of ERROR lines — the same "flood" harper#2001's
545
+ // original per-request log was fixed to avoid, just shifted from per-request to per-commit.
546
+ // A commit skipped by the cooldown is NOT marked `logged`, so it still gets a log later if
547
+ // it's still the oldest once the cooldown clears, rather than going silent forever.
548
+ if (!oldestOutstandingCommit.logged && now - lastOverloadLogAt > OVERLOAD_LOG_MIN_INTERVAL_MS) {
388
549
  // Log once per stuck commit (not once per rejected request, harper#2001): a wedged
389
550
  // thread otherwise logs nothing at all server-side while rejecting every write with a
390
- // 503, which was the single biggest obstacle to root-causing a recurrence.
391
- outstandingCommitLogged = true;
392
- const nativeTransactionId = outstandingCommitNativeTransaction?.id;
551
+ // 503, which was the single biggest obstacle to root-causing a recurrence. The flag lives
552
+ // on the node itself, so if THIS commit settles while still over the limit and a
553
+ // different one is now oldest, that one logs too instead of staying silent forever.
554
+ oldestOutstandingCommit.logged = true;
555
+ lastOverloadLogAt = now;
556
+ const nativeTransactionId = oldestOutstandingCommit.nativeTransaction?.id;
557
+ const store = oldestOutstandingCommit.store;
558
+ const startedFrom = oldestOutstandingCommit.startedFrom;
393
559
  harperLogger.error(
394
560
  `Rejecting writes on this thread: a commit has been outstanding for ` +
395
- `${Math.round(performance.now() - outstandingCommitStart)}ms (exceeds the ` +
396
- `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${outstandingCommitStore?.rootStore?.databaseName ?? '?'}.${outstandingCommitStore?.name ?? '?'}` +
561
+ `${Math.round(now - oldestOutstandingCommit.start)}ms (exceeds the ` +
562
+ `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${store?.rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
397
563
  (nativeTransactionId !== undefined ? ` (transaction ${nativeTransactionId})` : '') +
398
- (outstandingCommitStartedFrom?.resourceName
399
- ? `, started from ${outstandingCommitStartedFrom.resourceName}${outstandingCommitStartedFrom.method ? '.' + outstandingCommitStartedFrom.method : ''}`
564
+ (startedFrom?.resourceName
565
+ ? `, started from ${startedFrom.resourceName}${startedFrom.method ? '.' + startedFrom.method : ''}`
400
566
  : '') +
401
567
  `. Further record updates and publishes from new application requests on this thread ` +
402
568
  `will be rejected with 503 until the commit settles or the process is restarted (deletes, ` +
@@ -410,6 +576,13 @@ export class DatabaseTransaction implements Transaction {
410
576
 
411
577
  addWrite(operation: TransactionWrite) {
412
578
  if (this.timedOut) throw transactionOpenTooLongError();
579
+ // A write is activity: it re-arms the idle limit on this link even though the reads it
580
+ // performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive
581
+ // and only an idle one holding write intents is reaped.
582
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget ?? 0);
583
+ // Independent write-recency signal for chainStillActive (see the field comment) — reads never
584
+ // touch this, only writes do.
585
+ this.writeTimeout = this.timeout;
413
586
  this.linkWrite(operation);
414
587
  this.writes.push(operation);
415
588
  if (!operation.deferSave) {
@@ -535,6 +708,14 @@ export class DatabaseTransaction implements Transaction {
535
708
  this.writes = this.writes.filter((write) => write); // filter out removed entries
536
709
  if (this.writes.length > 0) {
537
710
  if (!options.transaction) {
711
+ if (!replayedWritesWarned) {
712
+ replayedWritesWarned = true;
713
+ harperLogger.warn?.(
714
+ `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` +
715
+ (this.startedFrom ? `, from ${this.startedFrom.resourceName}.${this.startedFrom.method}` : '') +
716
+ `. Fully consume (or close) iterators before committing to avoid this. Logged once per process.`
717
+ );
718
+ }
538
719
  // Deliberately NOT marked isRetry and NOT carrying over the original's onCommit:
539
720
  // audit/txn-log entries batch natively on the transaction they were staged into and
540
721
  // are only durably written by that transaction's commit attempt (an abort discards
@@ -557,6 +738,28 @@ export class DatabaseTransaction implements Transaction {
557
738
  // re-staged the writes into it
558
739
  commitResolution = transaction.commit() as Promise<void>;
559
740
  recordCommitLatency(commitResolution, performance.now());
741
+ // Write-queue-depth accounting for this replay commit happens uniformly below, via
742
+ // trackOutstandingCommit(commitResolution) — see that function's comment. Omitting
743
+ // dedicated accounting here (as a prior version of this replay path did) used to leave
744
+ // write-transaction-queue-depth, the one metric that can observe a commit that never
745
+ // settles (harper#2001), reading zero for exactly this path.
746
+ }
747
+ // No commit will ever run on the retained handle — the replay above owns these
748
+ // writes — so this is the only place its write intents can be released. Left in
749
+ // place, other writers' coordinated-retry commits park on them until the last
750
+ // iterator finishes (harper#2001). Reads through the handle, including
751
+ // read-your-own-writes, keep working. Once only: a coordinated-retry or backoff
752
+ // round re-enters this branch on the same retained handle. Fenced like the other
753
+ // post-submit steps here: the replay commit is already in flight, so a throw must
754
+ // not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7
755
+ // lacks the method.
756
+ if (!this.writesAbandoned) {
757
+ this.writesAbandoned = true;
758
+ try {
759
+ (this.transaction as { abandonWrites?: () => void } | null)?.abandonWrites?.();
760
+ } catch (error) {
761
+ harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
762
+ }
560
763
  }
561
764
  } else {
562
765
  // no more reads need to be performed, just commit/abort based if there are any writes
@@ -571,30 +774,19 @@ export class DatabaseTransaction implements Transaction {
571
774
  // Promise<number | void>; it is handled in the resolve callback below.
572
775
  commitResolution = transaction.commit();
573
776
  // Record how long this commit stays outstanding (submit → settle) as a distribution
574
- // metric. This is the same clock the overload check uses (outstandingCommitStart is
575
- // stamped at submit), so a rising p99/p999 is the leading indicator for the
777
+ // metric. This is the same clock the overload check uses (trackOutstandingCommit
778
+ // stamps each attempt at submit), so a rising p99/p999 is the leading indicator for the
576
779
  // "Outstanding write transactions have too long of queue" (503) rejection. A transient-
577
- // conflict retry rejects this promise and issues a fresh commit(), but outstandingCommit
578
- // only re-arms for the backoff path (retries > 2, below) — the coordinated-retry and
579
- // retries<=2 paths re-commit synchronously while outstandingCommit is still set, so
580
- // recording per attempt here counts more attempts than the overload check ever arms for.
780
+ // conflict retry rejects this promise and issues a fresh commit(), which is tracked as
781
+ // its own attempt, so recording per attempt matches the overload semantics.
581
782
  // commitResolution's declared type (Promise<number | void> | void) doesn't narrow to
582
783
  // Promise<void> here because the widening union defeats flow analysis on the prior
583
784
  // cast assignment; re-assert it — this branch's commit() result is always a Promise.
584
785
  recordCommitLatency(commitResolution as Promise<void>, performance.now());
585
- // Count this commit against the write queue depth until the storage engine
586
- // resolves it. A transient-conflict retry rejects this promise and issues a
587
- // fresh commit() (re-entering here), so the enter/leave stays balanced. leaveWriteQueue
588
- // never throws, so the settled promise resolves and needs no rejection handling of its own.
589
- // The thenable guard protects against a future caller passing a non-Promise
590
- // `commitResolution` (today it is always rocksdb-js's async Transaction.commit()
591
- // result, guaranteed to be a Promise).
592
- enterWriteQueue();
593
- if (commitResolution && typeof (commitResolution as any).then === 'function') {
594
- commitResolution.then(leaveWriteQueue, leaveWriteQueue);
595
- } else {
596
- leaveWriteQueue();
597
- }
786
+ // Write-queue-depth accounting for this commit happens uniformly below, via
787
+ // trackOutstandingCommit(commitResolution) see that function's comment. A
788
+ // transient-conflict retry rejects this promise and issues a fresh commit()
789
+ // (re-entering here), which trackOutstandingCommit tracks as its own attempt.
598
790
  } else {
599
791
  try {
600
792
  commitResolution = transaction.abort();
@@ -609,43 +801,10 @@ export class DatabaseTransaction implements Transaction {
609
801
  }
610
802
 
611
803
  if (commitResolution) {
612
- // Known gap (pre-existing, not introduced here): this branch only arms when
613
- // outstandingCommit is currently unset. A coordinated-retry re-commit (below) or a
614
- // chained this.next.commit() runs synchronously inside this same commit's .then
615
- // handler, one microtask before the .catch().finally() below clears outstandingCommit
616
- // — so a retry or a second store's commit that itself wedges is never armed, and
617
- // neither checkOverloaded() nor this log will ever see it. Only the first store's
618
- // first commit attempt is covered.
619
- if (!outstandingCommit) {
620
- outstandingCommit = commitResolution;
621
- outstandingCommitStart = performance.now();
622
- // Snapshot the store/origin now rather than reading them off `this` lazily in
623
- // checkOverloaded(): `this` (a DatabaseTransaction) can be reused for a later immediate
624
- // commit while this native commit is still wedged, and that later commit's resolve
625
- // handler runs clearWrites() on this SAME object — a lazy read would then blank or
626
- // mis-attribute the identity out from under the deferred log. Read the table off the
627
- // write itself, not this.db, which is whichever table first claimed this per-database
628
- // transaction in txnForContext and so can name the wrong table when a transaction spans
629
- // more than one table in the same database. Both are stable references (a Store, and an
630
- // object set once and never mutated), so this holds no allocation and no lingering
631
- // reference to the transaction/writes/entries graph.
632
- outstandingCommitStore = this.writes[0]?.store;
633
- outstandingCommitStartedFrom = this.startedFrom;
634
- outstandingCommitNativeTransaction = transaction;
635
- outstandingCommitLogged = false;
636
- outstandingCommit
637
- // if `commitResolution` rejects with and `ERR_BUSY` error, the retry logic
638
- // will correct course, but the reject will still be propagated on the
639
- // `outstandingCommit` promise and needs to be caught and silenced
640
- .catch(() => {})
641
- .finally(() => {
642
- outstandingCommit = null;
643
- outstandingCommitStore = undefined;
644
- outstandingCommitStartedFrom = undefined;
645
- outstandingCommitNativeTransaction = undefined;
646
- outstandingCommitLogged = false;
647
- });
648
- }
804
+ // Read the table off the write itself, not this.db, which is whichever table first
805
+ // claimed this per-database transaction in txnForContext and so can name the wrong
806
+ // table when a transaction spans more than one table in the same database.
807
+ trackOutstandingCommit(commitResolution, this.writes[0]?.store, this.startedFrom, transaction);
649
808
  const completions = [];
650
809
  return commitResolution.then(
651
810
  (commitResult) => {
@@ -721,6 +880,7 @@ export class DatabaseTransaction implements Transaction {
721
880
  // now reset transactions tracking; this transaction be reused and committed again
722
881
  this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
723
882
  this.clearWrites();
883
+ this.releaseContext(!!options.doneWriting);
724
884
  this.next = null;
725
885
  let txnTime = this.timestamp;
726
886
  this.timestamp = 0; // reset the timestamp as well
@@ -802,6 +962,11 @@ export class DatabaseTransaction implements Transaction {
802
962
  } catch (abortError) {
803
963
  harperLogger.debug?.('aborting transaction after failed commit', abortError);
804
964
  }
965
+ // A terminal failure is just as final as a success — release the context's
966
+ // back-reference here too, or transaction.ts's onComplete() (which has no
967
+ // rejection handler of its own) would leave a long-lived context pinning this
968
+ // CLOSED wrapper forever.
969
+ this.releaseContext(!!options.doneWriting);
805
970
  throw error;
806
971
  }
807
972
  }
@@ -812,6 +977,7 @@ export class DatabaseTransaction implements Transaction {
812
977
  cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
813
978
  }
814
979
  this.clearWrites();
980
+ this.releaseContext(!!options.doneWriting);
815
981
  const txnResolution: CommitResolution = {
816
982
  txnTime: this.timestamp,
817
983
  };
@@ -846,6 +1012,13 @@ export class DatabaseTransaction implements Transaction {
846
1012
  }
847
1013
  // reset the transaction
848
1014
  this.clearWrites();
1015
+ // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
1016
+ // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
1017
+ // starting a fresh one) so the rest of the logical operation fails atomically via the
1018
+ // poison check in addWrite()/commit(), rather than silently landing a later write on a
1019
+ // brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
1020
+ // make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
1021
+ this.releaseContext(!this.timedOut);
849
1022
  }
850
1023
  /**
851
1024
  * Give up on a chain of linked transactions after exhausting conflict retries: poison every link
@@ -935,6 +1108,7 @@ export interface CommitResolution {
935
1108
  next?: CommitResolution;
936
1109
  }
937
1110
  export interface Transaction {
1111
+ timeoutBudget?: number;
938
1112
  commit(options): MaybePromise<CommitResolution>;
939
1113
  abort?(): any;
940
1114
  }
@@ -973,9 +1147,31 @@ export class ImmediateTransaction extends DatabaseTransaction {
973
1147
 
974
1148
  let timer;
975
1149
 
1150
+ /**
1151
+ * True when a link other than `txn` in the same multi-store chain was written recently enough to
1152
+ * still be active — i.e. its `writeTimeout` (set only by addWrite, see the field comment) hasn't
1153
+ * decayed to zero. Writes re-arm only the link that receives them, so a chain writing database B
1154
+ * while its head only reads A would otherwise be aborted by the head's own decay.
1155
+ */
1156
+ function chainStillActive(txn: DatabaseTransaction): boolean {
1157
+ for (let link: DatabaseTransaction = txn.next; link; link = link.next) {
1158
+ // A write-only link (e.g. a blind write to a second database, never itself read) never calls
1159
+ // getReadTxn, so it's never added to trackedTxns and the main loop below never decays it.
1160
+ // Decay it here instead, so an idle write-only link eventually expires rather than keeping the
1161
+ // whole chain immortal (harper#2001's blind-write shape).
1162
+ if (!trackedTxns.has(link) && link.writeTimeout > 0) link.writeTimeout -= txnExpiration;
1163
+ if (link.writeTimeout > 0) return true;
1164
+ }
1165
+ return false;
1166
+ }
1167
+
976
1168
  function startMonitoringTxns() {
977
1169
  timer = setInterval(function () {
978
1170
  for (const txn of trackedTxns) {
1171
+ // Decay write recency once per tick for every tracked link, independent of the `timeout`
1172
+ // branches below — a tracked link that keeps its own idle limit alive by reading must not
1173
+ // thereby keep chainStillActive believing it was written recently too.
1174
+ if (txn.writeTimeout > 0) txn.writeTimeout -= txnExpiration;
979
1175
  if (txn.timeout <= 0) {
980
1176
  const url = (txn.getContext() as any)?.url;
981
1177
  if (txn.open === TRANSACTION_STATE.CLOSED) {
@@ -991,6 +1187,12 @@ function startMonitoringTxns() {
991
1187
  }`
992
1188
  );
993
1189
  txn.releaseReadTxn();
1190
+ } else if (txn.hasPendingWrites() && chainStillActive(txn)) {
1191
+ // A later link in the chain was written recently (writes re-arm only the link that
1192
+ // receives them, and a multi-store transaction can be writing database B while this
1193
+ // head only reads A). The logical transaction is still active, so re-arm this link
1194
+ // rather than aborting the whole chain out from under it.
1195
+ txn.timeout = Math.max(txnExpiration, txn.timeoutBudget ?? 0);
994
1196
  } else if (txn.hasPendingWrites() && !txn.sourceApply && !txn.isReplay) {
995
1197
  // Abort and surface an error rather than force-committing a partial write set: silently
996
1198
  // committing on the application's behalf breaks atomicity and can leave orphaned
@@ -1026,7 +1228,7 @@ function startMonitoringTxns() {
1026
1228
  } catch (error) {
1027
1229
  harperLogger.debug?.(`Error committing timed out transaction: ${error.message}`);
1028
1230
  }
1029
- txn.timeout = txnExpiration;
1231
+ txn.timeout = Math.max(txnExpiration, txn.timeoutBudget ?? 0);
1030
1232
  }
1031
1233
  } else {
1032
1234
  txn.timeout -= txnExpiration;
@@ -1037,6 +1239,15 @@ function startMonitoringTxns() {
1037
1239
 
1038
1240
  startMonitoringTxns();
1039
1241
 
1242
+ /**
1243
+ * Test seam: re-arms the once-per-process replay warning. The whole unit suite shares one process,
1244
+ * so whichever test first drives a commit under open iterators consumes the warning for every test
1245
+ * after it.
1246
+ */
1247
+ export function resetReplayedWritesWarning() {
1248
+ replayedWritesWarned = false;
1249
+ }
1250
+
1040
1251
  export function setTxnExpiration(ms) {
1041
1252
  clearInterval(timer);
1042
1253
  txnExpiration = ms;
@@ -67,8 +67,14 @@ export interface Context {
67
67
  login?: (username: string, password: string) => Promise<string>;
68
68
  /** Describes the current cookie-based session if it is present and grants the capacity to delete it. authentication.enableSessions must be turned on in the harperdb-config.yaml */
69
69
  session?: Session;
70
- /** The database transaction object */
71
- transaction?: DatabaseTransaction;
70
+ /**
71
+ * The database transaction object. `undefined` means none was ever attached; `null` means one
72
+ * was attached and has since completed and released its back-reference (DatabaseTransaction.ts's
73
+ * releaseContext()) — kept `null` rather than deleting the property so a long-lived, hot context
74
+ * (e.g. an MQTT subscription context releasing/reattaching a transaction per message) doesn't
75
+ * repeatedly force V8 to deoptimize it into dictionary-mode property storage.
76
+ */
77
+ transaction?: DatabaseTransaction | null;
72
78
  /** If the operation that will be performed with this context should check user authorization */
73
79
  authorize?: boolean;
74
80
  /** The last modification time of any data that has been accessed with this context */