@harperfast/harper 5.2.0-beta.4 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/copyDb.ts +21 -4
  4. package/bin/harper.ts +43 -55
  5. package/bin/help.ts +216 -0
  6. package/components/Application.ts +236 -46
  7. package/components/ApplicationScope.ts +26 -0
  8. package/components/EntryHandler.ts +410 -105
  9. package/components/RuntimeModuleTracker.ts +189 -0
  10. package/components/Scope.ts +120 -30
  11. package/components/componentLoader.ts +177 -25
  12. package/components/deployLifecycle.ts +119 -33
  13. package/components/mcp/toolRegistry.ts +10 -0
  14. package/components/mcp/tools/application.ts +12 -5
  15. package/components/mcp/tools/operations.ts +9 -0
  16. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  17. package/components/mcp/tools/schemas/operations.ts +9 -0
  18. package/components/operations.js +5 -6
  19. package/components/operationsValidation.js +32 -2
  20. package/components/scopeMount.ts +150 -0
  21. package/config/configUtils.ts +12 -9
  22. package/config-root.schema.json +14 -0
  23. package/dataLayer/backupManifest.ts +102 -0
  24. package/dataLayer/blobBackup.ts +286 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
  26. package/dataLayer/hdbInfoController.ts +8 -0
  27. package/dataLayer/restoreMarker.ts +276 -0
  28. package/dataLayer/rocksdbBackup.ts +1100 -0
  29. package/dataLayer/schemaDescribe.ts +2 -1
  30. package/dist/bin/backup.d.ts +9 -0
  31. package/dist/bin/backup.js +192 -0
  32. package/dist/bin/backup.js.map +1 -0
  33. package/dist/bin/cliOperations.d.ts +13 -0
  34. package/dist/bin/cliOperations.js +89 -70
  35. package/dist/bin/cliOperations.js.map +1 -1
  36. package/dist/bin/copyDb.js +13 -2
  37. package/dist/bin/copyDb.js.map +1 -1
  38. package/dist/bin/harper.d.ts +13 -0
  39. package/dist/bin/harper.js +45 -56
  40. package/dist/bin/harper.js.map +1 -1
  41. package/dist/bin/help.d.ts +8 -0
  42. package/dist/bin/help.js +192 -0
  43. package/dist/bin/help.js.map +1 -0
  44. package/dist/components/Application.d.ts +16 -1
  45. package/dist/components/Application.js +210 -38
  46. package/dist/components/Application.js.map +1 -1
  47. package/dist/components/ApplicationScope.d.ts +7 -0
  48. package/dist/components/ApplicationScope.js +22 -0
  49. package/dist/components/ApplicationScope.js.map +1 -1
  50. package/dist/components/EntryHandler.d.ts +4 -4
  51. package/dist/components/EntryHandler.js +386 -95
  52. package/dist/components/EntryHandler.js.map +1 -1
  53. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  54. package/dist/components/RuntimeModuleTracker.js +189 -0
  55. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  56. package/dist/components/Scope.d.ts +38 -1
  57. package/dist/components/Scope.js +117 -28
  58. package/dist/components/Scope.js.map +1 -1
  59. package/dist/components/componentLoader.d.ts +2 -6
  60. package/dist/components/componentLoader.js +160 -18
  61. package/dist/components/componentLoader.js.map +1 -1
  62. package/dist/components/deployLifecycle.d.ts +6 -2
  63. package/dist/components/deployLifecycle.js +109 -31
  64. package/dist/components/deployLifecycle.js.map +1 -1
  65. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  66. package/dist/components/mcp/toolRegistry.js +10 -0
  67. package/dist/components/mcp/toolRegistry.js.map +1 -1
  68. package/dist/components/mcp/tools/application.js +11 -5
  69. package/dist/components/mcp/tools/application.js.map +1 -1
  70. package/dist/components/mcp/tools/operations.js +9 -0
  71. package/dist/components/mcp/tools/operations.js.map +1 -1
  72. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  73. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  74. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  75. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  76. package/dist/components/operations.js +6 -6
  77. package/dist/components/operations.js.map +1 -1
  78. package/dist/components/operationsValidation.js +34 -2
  79. package/dist/components/operationsValidation.js.map +1 -1
  80. package/dist/components/scopeMount.d.ts +86 -0
  81. package/dist/components/scopeMount.js +131 -0
  82. package/dist/components/scopeMount.js.map +1 -0
  83. package/dist/config/configUtils.js +13 -9
  84. package/dist/config/configUtils.js.map +1 -1
  85. package/dist/dataLayer/backupManifest.d.ts +26 -0
  86. package/dist/dataLayer/backupManifest.js +97 -0
  87. package/dist/dataLayer/backupManifest.js.map +1 -0
  88. package/dist/dataLayer/blobBackup.d.ts +87 -0
  89. package/dist/dataLayer/blobBackup.js +282 -0
  90. package/dist/dataLayer/blobBackup.js.map +1 -0
  91. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  92. package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
  93. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  94. package/dist/dataLayer/hdbInfoController.js +4 -0
  95. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  96. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  97. package/dist/dataLayer/restoreMarker.js +261 -0
  98. package/dist/dataLayer/restoreMarker.js.map +1 -0
  99. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  100. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  101. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  102. package/dist/dataLayer/schemaDescribe.js +2 -1
  103. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  104. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  105. package/dist/resources/DatabaseTransaction.js +282 -109
  106. package/dist/resources/DatabaseTransaction.js.map +1 -1
  107. package/dist/resources/ResourceInterface.d.ts +8 -5
  108. package/dist/resources/ResourceInterface.js.map +1 -1
  109. package/dist/resources/Resources.js +22 -4
  110. package/dist/resources/Resources.js.map +1 -1
  111. package/dist/resources/Table.d.ts +6 -5
  112. package/dist/resources/Table.js +114 -37
  113. package/dist/resources/Table.js.map +1 -1
  114. package/dist/resources/analytics/write.js +6 -6
  115. package/dist/resources/analytics/write.js.map +1 -1
  116. package/dist/resources/blob.d.ts +8 -1
  117. package/dist/resources/blob.js +31 -14
  118. package/dist/resources/blob.js.map +1 -1
  119. package/dist/resources/databases.d.ts +59 -1
  120. package/dist/resources/databases.js +386 -41
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/jsResource.d.ts +4 -26
  123. package/dist/resources/jsResource.js +5 -59
  124. package/dist/resources/jsResource.js.map +1 -1
  125. package/dist/resources/models/Models.d.ts +11 -1
  126. package/dist/resources/models/Models.js +10 -1
  127. package/dist/resources/models/Models.js.map +1 -1
  128. package/dist/resources/models/backendRegistry.d.ts +9 -0
  129. package/dist/resources/models/backendRegistry.js +10 -0
  130. package/dist/resources/models/backendRegistry.js.map +1 -1
  131. package/dist/resources/models/openaiStream.d.ts +16 -1
  132. package/dist/resources/models/openaiStream.js +113 -21
  133. package/dist/resources/models/openaiStream.js.map +1 -1
  134. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  135. package/dist/resources/models/v1/chatCompletions.js +115 -0
  136. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  137. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  138. package/dist/resources/models/v1/embeddings.js +71 -0
  139. package/dist/resources/models/v1/embeddings.js.map +1 -0
  140. package/dist/resources/models/v1/errors.d.ts +54 -0
  141. package/dist/resources/models/v1/errors.js +130 -0
  142. package/dist/resources/models/v1/errors.js.map +1 -0
  143. package/dist/resources/models/v1/index.d.ts +36 -0
  144. package/dist/resources/models/v1/index.js +75 -0
  145. package/dist/resources/models/v1/index.js.map +1 -0
  146. package/dist/resources/models/v1/models.d.ts +26 -0
  147. package/dist/resources/models/v1/models.js +44 -0
  148. package/dist/resources/models/v1/models.js.map +1 -0
  149. package/dist/resources/models/v1/translation.d.ts +133 -0
  150. package/dist/resources/models/v1/translation.js +298 -0
  151. package/dist/resources/models/v1/translation.js.map +1 -0
  152. package/dist/resources/roles.d.ts +1 -1
  153. package/dist/resources/roles.js +54 -7
  154. package/dist/resources/roles.js.map +1 -1
  155. package/dist/resources/transaction.js +0 -3
  156. package/dist/resources/transaction.js.map +1 -1
  157. package/dist/security/jsLoader.js +84 -33
  158. package/dist/security/jsLoader.js.map +1 -1
  159. package/dist/security/role.js +4 -0
  160. package/dist/security/role.js.map +1 -1
  161. package/dist/security/superUserGuard.d.ts +7 -0
  162. package/dist/security/superUserGuard.js +23 -0
  163. package/dist/security/superUserGuard.js.map +1 -0
  164. package/dist/security/tokenAuthentication.d.ts +0 -1
  165. package/dist/security/tokenAuthentication.js +6 -2
  166. package/dist/security/tokenAuthentication.js.map +1 -1
  167. package/dist/security/user.d.ts +6 -1
  168. package/dist/security/user.js +23 -1
  169. package/dist/security/user.js.map +1 -1
  170. package/dist/server/REST.js +25 -9
  171. package/dist/server/REST.js.map +1 -1
  172. package/dist/server/fastifyRoutes.js +15 -1
  173. package/dist/server/fastifyRoutes.js.map +1 -1
  174. package/dist/server/http.d.ts +23 -2
  175. package/dist/server/http.js +99 -12
  176. package/dist/server/http.js.map +1 -1
  177. package/dist/server/itc/serverHandlers.js +7 -1
  178. package/dist/server/itc/serverHandlers.js.map +1 -1
  179. package/dist/server/jobs/jobProcess.js +20 -1
  180. package/dist/server/jobs/jobProcess.js.map +1 -1
  181. package/dist/server/jobs/jobRunner.js +10 -0
  182. package/dist/server/jobs/jobRunner.js.map +1 -1
  183. package/dist/server/jobs/jobs.js +11 -0
  184. package/dist/server/jobs/jobs.js.map +1 -1
  185. package/dist/server/middlewareChain.d.ts +10 -1
  186. package/dist/server/middlewareChain.js +81 -21
  187. package/dist/server/middlewareChain.js.map +1 -1
  188. package/dist/server/operationsServer.d.ts +0 -2
  189. package/dist/server/operationsServer.js.map +1 -1
  190. package/dist/server/serverHelpers/multipartParser.js +9 -0
  191. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  192. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  193. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  194. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  195. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  196. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  197. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  198. package/dist/server/serverHelpers/serverHandlers.js +27 -7
  199. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  200. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  201. package/dist/server/serverHelpers/serverUtilities.js +21 -7
  202. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  203. package/dist/server/static.js +68 -21
  204. package/dist/server/static.js.map +1 -1
  205. package/dist/server/storageReclamation.d.ts +17 -0
  206. package/dist/server/storageReclamation.js +90 -6
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/socketRouter.js +20 -0
  209. package/dist/server/threads/socketRouter.js.map +1 -1
  210. package/dist/server/threads/threadServer.js +12 -0
  211. package/dist/server/threads/threadServer.js.map +1 -1
  212. package/dist/sqlEngine/diff/differential.js +7 -3
  213. package/dist/sqlEngine/diff/differential.js.map +1 -1
  214. package/dist/sqlTranslator/index.js +6 -1
  215. package/dist/sqlTranslator/index.js.map +1 -1
  216. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  217. package/dist/upgrade/upgradePrompt.js +22 -3
  218. package/dist/upgrade/upgradePrompt.js.map +1 -1
  219. package/dist/utility/OperationFunctionCaller.js +25 -6
  220. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  221. package/dist/utility/errors/commonErrors.d.ts +1 -0
  222. package/dist/utility/errors/commonErrors.js +1 -0
  223. package/dist/utility/errors/commonErrors.js.map +1 -1
  224. package/dist/utility/hdbTerms.d.ts +13 -1
  225. package/dist/utility/hdbTerms.js +13 -1
  226. package/dist/utility/hdbTerms.js.map +1 -1
  227. package/dist/utility/install/installer.js +32 -0
  228. package/dist/utility/install/installer.js.map +1 -1
  229. package/dist/utility/logging/harper_logger.d.ts +47 -0
  230. package/dist/utility/logging/harper_logger.js +803 -38
  231. package/dist/utility/logging/harper_logger.js.map +1 -1
  232. package/dist/utility/operation_authorization.js +13 -3
  233. package/dist/utility/operation_authorization.js.map +1 -1
  234. package/npm-shrinkwrap.json +198 -193
  235. package/package.json +12 -7
  236. package/resources/DESIGN.md +2 -0
  237. package/resources/DatabaseTransaction.ts +310 -104
  238. package/resources/ResourceInterface.ts +8 -5
  239. package/resources/Resources.ts +22 -4
  240. package/resources/Table.ts +341 -263
  241. package/resources/analytics/write.ts +22 -20
  242. package/resources/blob.ts +31 -14
  243. package/resources/databases.ts +387 -46
  244. package/resources/jsResource.ts +5 -62
  245. package/resources/models/Models.ts +14 -1
  246. package/resources/models/backendRegistry.ts +10 -0
  247. package/resources/models/openaiStream.ts +131 -19
  248. package/resources/models/v1/chatCompletions.ts +128 -0
  249. package/resources/models/v1/embeddings.ts +70 -0
  250. package/resources/models/v1/errors.ts +141 -0
  251. package/resources/models/v1/index.ts +72 -0
  252. package/resources/models/v1/models.ts +53 -0
  253. package/resources/models/v1/translation.ts +362 -0
  254. package/resources/roles.ts +67 -7
  255. package/resources/transaction.ts +0 -3
  256. package/security/jsLoader.ts +84 -30
  257. package/security/role.ts +7 -0
  258. package/security/superUserGuard.ts +20 -0
  259. package/security/tokenAuthentication.ts +6 -3
  260. package/security/user.ts +26 -1
  261. package/server/DESIGN.md +61 -34
  262. package/server/REST.ts +25 -9
  263. package/server/fastifyRoutes.ts +20 -1
  264. package/server/http.ts +100 -13
  265. package/server/itc/serverHandlers.js +7 -1
  266. package/server/jobs/jobProcess.ts +18 -1
  267. package/server/jobs/jobRunner.ts +10 -0
  268. package/server/jobs/jobs.ts +11 -0
  269. package/server/middlewareChain.ts +79 -20
  270. package/server/operationsServer.ts +0 -2
  271. package/server/serverHelpers/multipartParser.ts +9 -0
  272. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  273. package/server/serverHelpers/registeredOperations.ts +19 -15
  274. package/server/serverHelpers/serverHandlers.js +28 -7
  275. package/server/serverHelpers/serverUtilities.ts +29 -7
  276. package/server/static.ts +87 -23
  277. package/server/storageReclamation.ts +104 -8
  278. package/server/threads/socketRouter.ts +20 -0
  279. package/server/threads/threadServer.js +11 -0
  280. package/sqlTranslator/index.ts +6 -1
  281. package/static/defaultConfig.yaml +3 -0
  282. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
  283. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
  284. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
  285. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  286. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
  287. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  288. package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
  289. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
  290. package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
  291. package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
  292. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
  293. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  294. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
  295. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
  296. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
  297. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
  298. package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
  299. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
  300. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
  301. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  302. package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
  303. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
  304. package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
  305. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
  306. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  307. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  308. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
  309. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
  310. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  311. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  312. package/studio/web/index.html +1 -1
  313. package/upgrade/upgradePrompt.ts +22 -3
  314. package/utility/OperationFunctionCaller.ts +24 -3
  315. package/utility/errors/commonErrors.ts +2 -0
  316. package/utility/hdbTerms.ts +13 -1
  317. package/utility/install/installer.ts +37 -0
  318. package/utility/logging/harper_logger.ts +786 -41
  319. package/utility/operation_authorization.ts +31 -3
  320. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -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,12 @@ 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;
251
338
  validated = 0;
252
339
  timestamp = 0;
253
340
  retries = 0;
@@ -256,6 +343,10 @@ export class DatabaseTransaction implements Transaction {
256
343
  // Whether this read handle's base reference (readTxnsUsed starts at 1 in getReadTxn) has been
257
344
  // consumed by a commit round; iterator references are consumed only by doneReadTxn().
258
345
  declare baseReadRefConsumed?: boolean;
346
+ // Set when a final commit/abort wanted to release the context's back-reference (see
347
+ // releaseContext()) but outstanding read iterators were still using this transaction —
348
+ // doneReadTxn() completes the release once the last iterator drains.
349
+ declare pendingContextRelease?: boolean;
259
350
  declare startedFrom?: {
260
351
  resourceName: string;
261
352
  method: string;
@@ -278,10 +369,24 @@ export class DatabaseTransaction implements Transaction {
278
369
  // open-transaction limit. Once poisoned, any further addWrite/commit throws transactionOpenTooLongError
279
370
  // so the request rolls back cleanly instead of silently committing a partial write set (issue #1407).
280
371
  declare timedOut?: boolean;
372
+ // Set once the retained read handle's write intents have been released (see commit()'s
373
+ // outstanding-iterators branch), so a retry round cannot re-fire the release.
374
+ declare writesAbandoned?: boolean;
281
375
 
282
376
  getReadTxn(disableSnapshot?: boolean): ReadTransaction {
283
377
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
284
- this.timeout = txnExpiration; // reset the timeout
378
+ // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so
379
+ // while no uncommitted writes are held: staged writes hold write intents that other writers'
380
+ // coordinated-retry commits park on, so a handler that wrote once and then only reads — an
381
+ // orphaned long-poll whose client had already gone, in harper#2001 — must not keep those
382
+ // intents alive by reading. A transaction that keeps writing stays alive; so does a purely
383
+ // read-only one. A committed transaction re-arms too: its intents went with the commit, and
384
+ // the monitor bounds its retained read snapshot separately.
385
+ // `writes`/`next` are checked inline first: this is a hot path and the dominant case is a
386
+ // single-store transaction that has never written.
387
+ if ((this.writes.length === 0 && !this.next) || this.open !== TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
388
+ this.timeout = txnExpiration;
389
+ }
285
390
  if (this.transaction) {
286
391
  if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0;
287
392
  return this.transaction;
@@ -328,6 +433,7 @@ export class DatabaseTransaction implements Transaction {
328
433
  trackedTxns.delete(this);
329
434
  this.transaction?.abort();
330
435
  this.transaction = null;
436
+ this.completeDeferredContextRelease();
331
437
  }
332
438
  }
333
439
 
@@ -346,6 +452,19 @@ export class DatabaseTransaction implements Transaction {
346
452
  harperLogger.debug?.('releasing timed-out read transaction', error);
347
453
  }
348
454
  this.transaction = null;
455
+ this.completeDeferredContextRelease();
456
+ }
457
+
458
+ /**
459
+ * Complete a context release that releaseContext() deferred because outstanding read iterators
460
+ * were still using this transaction (see releaseContext()) — called once the last one drains,
461
+ * whether that happens naturally (doneReadTxn()) or is forced by the long-transaction monitor
462
+ * (releaseReadTxn()).
463
+ */
464
+ private completeDeferredContextRelease(): void {
465
+ if (!this.pendingContextRelease) return;
466
+ this.pendingContextRelease = false;
467
+ if (this.#context?.transaction === this) this.#context.transaction = null;
349
468
  }
350
469
 
351
470
  disregardReadTxn(): void {
@@ -378,25 +497,71 @@ export class DatabaseTransaction implements Transaction {
378
497
  this.writesByKey = undefined;
379
498
  }
380
499
 
500
+ /**
501
+ * Drop this transaction's back-reference from its context once completed (commit or abort),
502
+ * so a long-lived context (e.g. an MQTT subscription context held open for the life of a
503
+ * suspended delivery loop) doesn't keep pinning a finished transaction in memory. Guarded by
504
+ * identity: a context already re-pointed at a different (e.g. reused) transaction is untouched.
505
+ *
506
+ * `final` must be false for an in-callback explicit `context.transaction.commit()` — the
507
+ * "commit in the middle" pattern intentionally keeps recommitting and adding writes to the
508
+ * SAME instance (see the comment above about a transaction being "reused and committed
509
+ * again"), so releasing here would strand those later writes with no transaction to join.
510
+ * Only resources/transaction.ts's own wrapper commit (`{ doneWriting: true }`, once the
511
+ * caller's callback has fully returned) and abort() are truly final.
512
+ *
513
+ * A final commit can still have outstanding read iterators streaming through this.transaction
514
+ * (see the outstanding-iterators branch in commit()) — those keep this instance meaningfully
515
+ * alive (a fresh write on the same context must not join a DIFFERENT, already-replayed
516
+ * transaction) until doneReadTxn() drains the last one, so the release is deferred to there.
517
+ *
518
+ * Sets `.transaction` to `null` rather than deleting the property: `Context.transaction` is
519
+ * typed `DatabaseTransaction | null | undefined` precisely to document this released state, and
520
+ * `delete` would repeatedly force a long-lived, hot context (e.g. an MQTT subscription context
521
+ * releasing/reattaching a transaction per message) into V8's slower dictionary-mode property
522
+ * storage.
523
+ */
524
+ private releaseContext(final: boolean): void {
525
+ if (!final) return;
526
+ if (this.readTxnsUsed > 0) {
527
+ this.pendingContextRelease = true;
528
+ return;
529
+ }
530
+ if (this.#context?.transaction === this) this.#context.transaction = null;
531
+ }
532
+
381
533
  checkOverloaded() {
382
534
  if (
383
- outstandingCommit &&
535
+ oldestOutstandingCommit &&
384
536
  !this.overloadChecked &&
385
- performance.now() - outstandingCommitStart > MAX_OUTSTANDING_TXN_DURATION
537
+ performance.now() - oldestOutstandingCommit.start > MAX_OUTSTANDING_TXN_DURATION
386
538
  ) {
387
- if (!outstandingCommitLogged) {
539
+ const now = performance.now();
540
+ // Also rate-limited across the whole overload episode (not just deduped per commit): under
541
+ // sustained heavy load, many distinct commits can each individually age past the limit in
542
+ // quick succession as earlier ones finally settle, which without this cap would turn one
543
+ // overload episode into a growing stream of ERROR lines — the same "flood" harper#2001's
544
+ // original per-request log was fixed to avoid, just shifted from per-request to per-commit.
545
+ // A commit skipped by the cooldown is NOT marked `logged`, so it still gets a log later if
546
+ // it's still the oldest once the cooldown clears, rather than going silent forever.
547
+ if (!oldestOutstandingCommit.logged && now - lastOverloadLogAt > OVERLOAD_LOG_MIN_INTERVAL_MS) {
388
548
  // Log once per stuck commit (not once per rejected request, harper#2001): a wedged
389
549
  // 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;
550
+ // 503, which was the single biggest obstacle to root-causing a recurrence. The flag lives
551
+ // on the node itself, so if THIS commit settles while still over the limit and a
552
+ // different one is now oldest, that one logs too instead of staying silent forever.
553
+ oldestOutstandingCommit.logged = true;
554
+ lastOverloadLogAt = now;
555
+ const nativeTransactionId = oldestOutstandingCommit.nativeTransaction?.id;
556
+ const store = oldestOutstandingCommit.store;
557
+ const startedFrom = oldestOutstandingCommit.startedFrom;
393
558
  harperLogger.error(
394
559
  `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 ?? '?'}` +
560
+ `${Math.round(now - oldestOutstandingCommit.start)}ms (exceeds the ` +
561
+ `${MAX_OUTSTANDING_TXN_DURATION}ms limit), from table: ${store?.rootStore?.databaseName ?? '?'}.${store?.name ?? '?'}` +
397
562
  (nativeTransactionId !== undefined ? ` (transaction ${nativeTransactionId})` : '') +
398
- (outstandingCommitStartedFrom?.resourceName
399
- ? `, started from ${outstandingCommitStartedFrom.resourceName}${outstandingCommitStartedFrom.method ? '.' + outstandingCommitStartedFrom.method : ''}`
563
+ (startedFrom?.resourceName
564
+ ? `, started from ${startedFrom.resourceName}${startedFrom.method ? '.' + startedFrom.method : ''}`
400
565
  : '') +
401
566
  `. Further record updates and publishes from new application requests on this thread ` +
402
567
  `will be rejected with 503 until the commit settles or the process is restarted (deletes, ` +
@@ -410,6 +575,13 @@ export class DatabaseTransaction implements Transaction {
410
575
 
411
576
  addWrite(operation: TransactionWrite) {
412
577
  if (this.timedOut) throw transactionOpenTooLongError();
578
+ // A write is activity: it re-arms the idle limit on this link even though the reads it
579
+ // performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive
580
+ // and only an idle one holding write intents is reaped.
581
+ this.timeout = txnExpiration;
582
+ // Independent write-recency signal for chainStillActive (see the field comment) — reads never
583
+ // touch this, only writes do.
584
+ this.writeTimeout = txnExpiration;
413
585
  this.linkWrite(operation);
414
586
  this.writes.push(operation);
415
587
  if (!operation.deferSave) {
@@ -535,6 +707,14 @@ export class DatabaseTransaction implements Transaction {
535
707
  this.writes = this.writes.filter((write) => write); // filter out removed entries
536
708
  if (this.writes.length > 0) {
537
709
  if (!options.transaction) {
710
+ if (!replayedWritesWarned) {
711
+ replayedWritesWarned = true;
712
+ harperLogger.warn?.(
713
+ `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` +
714
+ (this.startedFrom ? `, from ${this.startedFrom.resourceName}.${this.startedFrom.method}` : '') +
715
+ `. Fully consume (or close) iterators before committing to avoid this. Logged once per process.`
716
+ );
717
+ }
538
718
  // Deliberately NOT marked isRetry and NOT carrying over the original's onCommit:
539
719
  // audit/txn-log entries batch natively on the transaction they were staged into and
540
720
  // are only durably written by that transaction's commit attempt (an abort discards
@@ -557,6 +737,28 @@ export class DatabaseTransaction implements Transaction {
557
737
  // re-staged the writes into it
558
738
  commitResolution = transaction.commit() as Promise<void>;
559
739
  recordCommitLatency(commitResolution, performance.now());
740
+ // Write-queue-depth accounting for this replay commit happens uniformly below, via
741
+ // trackOutstandingCommit(commitResolution) — see that function's comment. Omitting
742
+ // dedicated accounting here (as a prior version of this replay path did) used to leave
743
+ // write-transaction-queue-depth, the one metric that can observe a commit that never
744
+ // settles (harper#2001), reading zero for exactly this path.
745
+ }
746
+ // No commit will ever run on the retained handle — the replay above owns these
747
+ // writes — so this is the only place its write intents can be released. Left in
748
+ // place, other writers' coordinated-retry commits park on them until the last
749
+ // iterator finishes (harper#2001). Reads through the handle, including
750
+ // read-your-own-writes, keep working. Once only: a coordinated-retry or backoff
751
+ // round re-enters this branch on the same retained handle. Fenced like the other
752
+ // post-submit steps here: the replay commit is already in flight, so a throw must
753
+ // not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7
754
+ // lacks the method.
755
+ if (!this.writesAbandoned) {
756
+ this.writesAbandoned = true;
757
+ try {
758
+ (this.transaction as { abandonWrites?: () => void } | null)?.abandonWrites?.();
759
+ } catch (error) {
760
+ harperLogger.warn?.('Failed to release write intents on a retained read transaction', error);
761
+ }
560
762
  }
561
763
  } else {
562
764
  // no more reads need to be performed, just commit/abort based if there are any writes
@@ -571,30 +773,19 @@ export class DatabaseTransaction implements Transaction {
571
773
  // Promise<number | void>; it is handled in the resolve callback below.
572
774
  commitResolution = transaction.commit();
573
775
  // 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
776
+ // metric. This is the same clock the overload check uses (trackOutstandingCommit
777
+ // stamps each attempt at submit), so a rising p99/p999 is the leading indicator for the
576
778
  // "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.
779
+ // conflict retry rejects this promise and issues a fresh commit(), which is tracked as
780
+ // its own attempt, so recording per attempt matches the overload semantics.
581
781
  // commitResolution's declared type (Promise<number | void> | void) doesn't narrow to
582
782
  // Promise<void> here because the widening union defeats flow analysis on the prior
583
783
  // cast assignment; re-assert it — this branch's commit() result is always a Promise.
584
784
  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
- }
785
+ // Write-queue-depth accounting for this commit happens uniformly below, via
786
+ // trackOutstandingCommit(commitResolution) see that function's comment. A
787
+ // transient-conflict retry rejects this promise and issues a fresh commit()
788
+ // (re-entering here), which trackOutstandingCommit tracks as its own attempt.
598
789
  } else {
599
790
  try {
600
791
  commitResolution = transaction.abort();
@@ -609,43 +800,10 @@ export class DatabaseTransaction implements Transaction {
609
800
  }
610
801
 
611
802
  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
- }
803
+ // Read the table off the write itself, not this.db, which is whichever table first
804
+ // claimed this per-database transaction in txnForContext and so can name the wrong
805
+ // table when a transaction spans more than one table in the same database.
806
+ trackOutstandingCommit(commitResolution, this.writes[0]?.store, this.startedFrom, transaction);
649
807
  const completions = [];
650
808
  return commitResolution.then(
651
809
  (commitResult) => {
@@ -721,7 +879,7 @@ export class DatabaseTransaction implements Transaction {
721
879
  // now reset transactions tracking; this transaction be reused and committed again
722
880
  this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
723
881
  this.clearWrites();
724
- if (this.#context?.resourceCache) this.#context.resourceCache = null;
882
+ this.releaseContext(!!options.doneWriting);
725
883
  this.next = null;
726
884
  let txnTime = this.timestamp;
727
885
  this.timestamp = 0; // reset the timestamp as well
@@ -803,6 +961,11 @@ export class DatabaseTransaction implements Transaction {
803
961
  } catch (abortError) {
804
962
  harperLogger.debug?.('aborting transaction after failed commit', abortError);
805
963
  }
964
+ // A terminal failure is just as final as a success — release the context's
965
+ // back-reference here too, or transaction.ts's onComplete() (which has no
966
+ // rejection handler of its own) would leave a long-lived context pinning this
967
+ // CLOSED wrapper forever.
968
+ this.releaseContext(!!options.doneWriting);
806
969
  throw error;
807
970
  }
808
971
  }
@@ -813,7 +976,7 @@ export class DatabaseTransaction implements Transaction {
813
976
  cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
814
977
  }
815
978
  this.clearWrites();
816
- if (this.#context?.resourceCache) this.#context.resourceCache = null;
979
+ this.releaseContext(!!options.doneWriting);
817
980
  const txnResolution: CommitResolution = {
818
981
  txnTime: this.timestamp,
819
982
  };
@@ -848,7 +1011,13 @@ export class DatabaseTransaction implements Transaction {
848
1011
  }
849
1012
  // reset the transaction
850
1013
  this.clearWrites();
851
- if (this.#context?.resourceCache) this.#context.resourceCache = null;
1014
+ // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
1015
+ // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
1016
+ // starting a fresh one) so the rest of the logical operation fails atomically via the
1017
+ // poison check in addWrite()/commit(), rather than silently landing a later write on a
1018
+ // brand-new transaction after an earlier one was rolled back (#1411). Releasing here would
1019
+ // make that check see `undefined?.timedOut` and take the "start fresh" branch instead.
1020
+ this.releaseContext(!this.timedOut);
852
1021
  }
853
1022
  /**
854
1023
  * Give up on a chain of linked transactions after exhausting conflict retries: poison every link
@@ -976,9 +1145,31 @@ export class ImmediateTransaction extends DatabaseTransaction {
976
1145
 
977
1146
  let timer;
978
1147
 
1148
+ /**
1149
+ * True when a link other than `txn` in the same multi-store chain was written recently enough to
1150
+ * still be active — i.e. its `writeTimeout` (set only by addWrite, see the field comment) hasn't
1151
+ * decayed to zero. Writes re-arm only the link that receives them, so a chain writing database B
1152
+ * while its head only reads A would otherwise be aborted by the head's own decay.
1153
+ */
1154
+ function chainStillActive(txn: DatabaseTransaction): boolean {
1155
+ for (let link: DatabaseTransaction = txn.next; link; link = link.next) {
1156
+ // A write-only link (e.g. a blind write to a second database, never itself read) never calls
1157
+ // getReadTxn, so it's never added to trackedTxns and the main loop below never decays it.
1158
+ // Decay it here instead, so an idle write-only link eventually expires rather than keeping the
1159
+ // whole chain immortal (harper#2001's blind-write shape).
1160
+ if (!trackedTxns.has(link) && link.writeTimeout > 0) link.writeTimeout -= txnExpiration;
1161
+ if (link.writeTimeout > 0) return true;
1162
+ }
1163
+ return false;
1164
+ }
1165
+
979
1166
  function startMonitoringTxns() {
980
1167
  timer = setInterval(function () {
981
1168
  for (const txn of trackedTxns) {
1169
+ // Decay write recency once per tick for every tracked link, independent of the `timeout`
1170
+ // branches below — a tracked link that keeps its own idle limit alive by reading must not
1171
+ // thereby keep chainStillActive believing it was written recently too.
1172
+ if (txn.writeTimeout > 0) txn.writeTimeout -= txnExpiration;
982
1173
  if (txn.timeout <= 0) {
983
1174
  const url = (txn.getContext() as any)?.url;
984
1175
  if (txn.open === TRANSACTION_STATE.CLOSED) {
@@ -994,6 +1185,12 @@ function startMonitoringTxns() {
994
1185
  }`
995
1186
  );
996
1187
  txn.releaseReadTxn();
1188
+ } else if (txn.hasPendingWrites() && chainStillActive(txn)) {
1189
+ // A later link in the chain was written recently (writes re-arm only the link that
1190
+ // receives them, and a multi-store transaction can be writing database B while this
1191
+ // head only reads A). The logical transaction is still active, so re-arm this link
1192
+ // rather than aborting the whole chain out from under it.
1193
+ txn.timeout = txnExpiration;
997
1194
  } else if (txn.hasPendingWrites() && !txn.sourceApply && !txn.isReplay) {
998
1195
  // Abort and surface an error rather than force-committing a partial write set: silently
999
1196
  // committing on the application's behalf breaks atomicity and can leave orphaned
@@ -1040,6 +1237,15 @@ function startMonitoringTxns() {
1040
1237
 
1041
1238
  startMonitoringTxns();
1042
1239
 
1240
+ /**
1241
+ * Test seam: re-arms the once-per-process replay warning. The whole unit suite shares one process,
1242
+ * so whichever test first drives a commit under open iterators consumes the warning for every test
1243
+ * after it.
1244
+ */
1245
+ export function resetReplayedWritesWarning() {
1246
+ replayedWritesWarned = false;
1247
+ }
1248
+
1043
1249
  export function setTxnExpiration(ms) {
1044
1250
  clearInterval(timer);
1045
1251
  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 */
@@ -95,7 +101,6 @@ export interface Context {
95
101
  originatingOperation?: OperationFunctionName;
96
102
  previousResidency?: string[];
97
103
  nodeName?: string;
98
- resourceCache?: Map<Id, any>;
99
104
  _freezeRecords?: boolean; // until v5, we conditionally freeze records for back-compat
100
105
  timestamp?: number;
101
106
  includeExpensiveRecordCountEstimates?: boolean;
@@ -127,8 +132,6 @@ export interface SourceContext<TRequestContext = Context, Record extends object
127
132
  noCacheStore?: boolean;
128
133
  /** Reference to the source Resource instance */
129
134
  source?: ResourceInterface<Record>;
130
- /** Shared resource cache from parent context for visibility of modifications */
131
- resourceCache?: Map<Id, any>;
132
135
  /** Database transaction for the context */
133
136
  transaction?: DatabaseTransaction;
134
137
  /** The time at which the cached entry should expire (ms since epoch) */