@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.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 (347) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/DESIGN.md +421 -0
  5. package/components/OptionsWatcher.ts +368 -102
  6. package/components/Scope.ts +20 -1
  7. package/components/componentLoader.ts +43 -4
  8. package/components/deploymentOperations.ts +4 -1
  9. package/components/deploymentRecorder.ts +9 -2
  10. package/components/mcp/DESIGN.md +109 -0
  11. package/components/mcp/audit.ts +21 -17
  12. package/components/operations.js +284 -52
  13. package/components/operationsValidation.js +49 -2
  14. package/components/packageComponent.ts +25 -1
  15. package/components/requestRestart.ts +11 -0
  16. package/config/DESIGN.md +306 -0
  17. package/config/RootConfigWatcher.ts +191 -37
  18. package/config/configReadRetry.ts +62 -0
  19. package/config/configUtils.ts +78 -26
  20. package/config/parseConfigFile.ts +34 -0
  21. package/config/readConfigFileSync.ts +44 -0
  22. package/config/watcherArming.ts +59 -0
  23. package/config-root.schema.json +4 -0
  24. package/dataLayer/DESIGN.md +179 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  26. package/dataLayer/restoreMarker.ts +92 -25
  27. package/dist/bin/cliOperations.js +13 -0
  28. package/dist/bin/cliOperations.js.map +1 -1
  29. package/dist/bin/restart.js +42 -6
  30. package/dist/bin/restart.js.map +1 -1
  31. package/dist/components/Application.d.ts +104 -9
  32. package/dist/components/Application.js +954 -102
  33. package/dist/components/Application.js.map +1 -1
  34. package/dist/components/OptionsWatcher.d.ts +4 -1
  35. package/dist/components/OptionsWatcher.js +378 -104
  36. package/dist/components/OptionsWatcher.js.map +1 -1
  37. package/dist/components/Scope.js +15 -1
  38. package/dist/components/Scope.js.map +1 -1
  39. package/dist/components/componentLoader.js +35 -3
  40. package/dist/components/componentLoader.js.map +1 -1
  41. package/dist/components/deploymentOperations.js +4 -1
  42. package/dist/components/deploymentOperations.js.map +1 -1
  43. package/dist/components/deploymentRecorder.d.ts +4 -2
  44. package/dist/components/deploymentRecorder.js +1 -0
  45. package/dist/components/deploymentRecorder.js.map +1 -1
  46. package/dist/components/mcp/audit.d.ts +2 -1
  47. package/dist/components/mcp/audit.js +21 -17
  48. package/dist/components/mcp/audit.js.map +1 -1
  49. package/dist/components/operations.d.ts +28 -0
  50. package/dist/components/operations.js +263 -54
  51. package/dist/components/operations.js.map +1 -1
  52. package/dist/components/operationsValidation.js +48 -2
  53. package/dist/components/operationsValidation.js.map +1 -1
  54. package/dist/components/packageComponent.js +24 -0
  55. package/dist/components/packageComponent.js.map +1 -1
  56. package/dist/components/requestRestart.d.ts +1 -0
  57. package/dist/components/requestRestart.js +7 -0
  58. package/dist/components/requestRestart.js.map +1 -1
  59. package/dist/config/RootConfigWatcher.d.ts +2 -0
  60. package/dist/config/RootConfigWatcher.js +189 -35
  61. package/dist/config/RootConfigWatcher.js.map +1 -1
  62. package/dist/config/configReadRetry.d.ts +8 -0
  63. package/dist/config/configReadRetry.js +62 -0
  64. package/dist/config/configReadRetry.js.map +1 -0
  65. package/dist/config/configUtils.d.ts +10 -9
  66. package/dist/config/configUtils.js +63 -27
  67. package/dist/config/configUtils.js.map +1 -1
  68. package/dist/config/parseConfigFile.d.ts +4 -0
  69. package/dist/config/parseConfigFile.js +35 -0
  70. package/dist/config/parseConfigFile.js.map +1 -0
  71. package/dist/config/readConfigFileSync.d.ts +1 -0
  72. package/dist/config/readConfigFileSync.js +47 -0
  73. package/dist/config/readConfigFileSync.js.map +1 -0
  74. package/dist/config/watcherArming.d.ts +15 -0
  75. package/dist/config/watcherArming.js +59 -0
  76. package/dist/config/watcherArming.js.map +1 -0
  77. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  78. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  79. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  80. package/dist/dataLayer/restoreMarker.js +94 -27
  81. package/dist/dataLayer/restoreMarker.js.map +1 -1
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +9 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/json/systemSchema.json +3 -0
  86. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  87. package/dist/resources/DatabaseTransaction.js +224 -18
  88. package/dist/resources/DatabaseTransaction.js.map +1 -1
  89. package/dist/resources/LMDBTransaction.d.ts +2 -1
  90. package/dist/resources/LMDBTransaction.js +22 -3
  91. package/dist/resources/LMDBTransaction.js.map +1 -1
  92. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  93. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  94. package/dist/resources/RecordEncoder.d.ts +1 -1
  95. package/dist/resources/RecordEncoder.js +46 -10
  96. package/dist/resources/RecordEncoder.js.map +1 -1
  97. package/dist/resources/Resource.js +97 -13
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/ResourceInterface.d.ts +8 -0
  100. package/dist/resources/RocksIndexStore.js +2 -1
  101. package/dist/resources/RocksIndexStore.js.map +1 -1
  102. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  103. package/dist/resources/RocksTransactionLogStore.js +104 -33
  104. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  105. package/dist/resources/Table.d.ts +58 -7
  106. package/dist/resources/Table.js +1143 -348
  107. package/dist/resources/Table.js.map +1 -1
  108. package/dist/resources/analytics/write.d.ts +3 -0
  109. package/dist/resources/analytics/write.js +49 -16
  110. package/dist/resources/analytics/write.js.map +1 -1
  111. package/dist/resources/auditStore.d.ts +170 -0
  112. package/dist/resources/auditStore.js +457 -11
  113. package/dist/resources/auditStore.js.map +1 -1
  114. package/dist/resources/crdt.d.ts +10 -0
  115. package/dist/resources/crdt.js +22 -0
  116. package/dist/resources/crdt.js.map +1 -1
  117. package/dist/resources/dataLoader.js +3 -4
  118. package/dist/resources/dataLoader.js.map +1 -1
  119. package/dist/resources/databases.d.ts +16 -13
  120. package/dist/resources/databases.js +625 -177
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  123. package/dist/resources/derivedIndexRegistry.js +68 -0
  124. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  125. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  126. package/dist/resources/derivedIndexRuntime.js +2027 -0
  127. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  128. package/dist/resources/graphql.js +3 -2
  129. package/dist/resources/graphql.js.map +1 -1
  130. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  131. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
  132. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  133. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  134. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  135. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  136. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  137. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  138. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  139. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  140. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  141. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  142. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  143. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  144. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  145. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  146. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  147. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  148. package/dist/resources/nodeIdMapping.d.ts +5 -0
  149. package/dist/resources/nodeIdMapping.js +49 -0
  150. package/dist/resources/nodeIdMapping.js.map +1 -1
  151. package/dist/resources/recordLock.d.ts +47 -4
  152. package/dist/resources/recordLock.js +138 -7
  153. package/dist/resources/recordLock.js.map +1 -1
  154. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  155. package/dist/resources/recordLockCoordinator.js +2597 -0
  156. package/dist/resources/recordLockCoordinator.js.map +1 -0
  157. package/dist/resources/replayLogs.js +5 -0
  158. package/dist/resources/replayLogs.js.map +1 -1
  159. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  160. package/dist/resources/replicatedApplyFailure.js +63 -0
  161. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  162. package/dist/resources/scheduler/scheduler.js +3 -3
  163. package/dist/resources/scheduler/scheduler.js.map +1 -1
  164. package/dist/resources/search.d.ts +10 -4
  165. package/dist/resources/search.js +160 -40
  166. package/dist/resources/search.js.map +1 -1
  167. package/dist/resources/tracked.d.ts +5 -1
  168. package/dist/resources/tracked.js +74 -23
  169. package/dist/resources/tracked.js.map +1 -1
  170. package/dist/resources/transactionBroadcast.js +4 -6
  171. package/dist/resources/transactionBroadcast.js.map +1 -1
  172. package/dist/security/auth.js +59 -23
  173. package/dist/security/auth.js.map +1 -1
  174. package/dist/security/deferredAuthentication.d.ts +11 -0
  175. package/dist/security/deferredAuthentication.js +25 -3
  176. package/dist/security/deferredAuthentication.js.map +1 -1
  177. package/dist/security/jsLoader.js +9 -4
  178. package/dist/security/jsLoader.js.map +1 -1
  179. package/dist/server/REST.js +39 -5
  180. package/dist/server/REST.js.map +1 -1
  181. package/dist/server/http.d.ts +5 -1
  182. package/dist/server/http.js +34 -2
  183. package/dist/server/http.js.map +1 -1
  184. package/dist/server/mqtt.js +5 -1
  185. package/dist/server/mqtt.js.map +1 -1
  186. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  187. package/dist/server/serverHelpers/Headers.js +6 -0
  188. package/dist/server/serverHelpers/Headers.js.map +1 -1
  189. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  190. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  191. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  192. package/dist/server/serverHelpers/Request.d.ts +5 -10
  193. package/dist/server/serverHelpers/Request.js +38 -136
  194. package/dist/server/serverHelpers/Request.js.map +1 -1
  195. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  196. package/dist/server/serverHelpers/contentTypes.js +189 -15
  197. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  198. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  199. package/dist/server/serverHelpers/serverUtilities.js +110 -20
  200. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  201. package/dist/server/serverHelpers/uwsServer.js +4 -1
  202. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  203. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  204. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  205. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  206. package/dist/server/storageReclamation.js +1 -1
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  209. package/dist/server/threads/isolatedApplications.js +171 -0
  210. package/dist/server/threads/isolatedApplications.js.map +1 -0
  211. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  212. package/dist/server/threads/logRotationTransport.js +33 -0
  213. package/dist/server/threads/logRotationTransport.js.map +1 -0
  214. package/dist/server/threads/manageThreads.d.ts +64 -6
  215. package/dist/server/threads/manageThreads.js +261 -12
  216. package/dist/server/threads/manageThreads.js.map +1 -1
  217. package/dist/server/threads/socketRouter.d.ts +1 -0
  218. package/dist/server/threads/socketRouter.js +196 -13
  219. package/dist/server/threads/socketRouter.js.map +1 -1
  220. package/dist/server/threads/threadServer.js +30 -7
  221. package/dist/server/threads/threadServer.js.map +1 -1
  222. package/dist/utility/errors/hdbError.d.ts +24 -0
  223. package/dist/utility/errors/hdbError.js +58 -1
  224. package/dist/utility/errors/hdbError.js.map +1 -1
  225. package/dist/utility/hdbTerms.d.ts +2 -0
  226. package/dist/utility/hdbTerms.js +2 -0
  227. package/dist/utility/hdbTerms.js.map +1 -1
  228. package/dist/utility/logging/harper_logger.js +218 -38
  229. package/dist/utility/logging/harper_logger.js.map +1 -1
  230. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  231. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  232. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  233. package/dist/utility/logging/logRotation.d.ts +46 -0
  234. package/dist/utility/logging/logRotation.js +365 -0
  235. package/dist/utility/logging/logRotation.js.map +1 -0
  236. package/dist/utility/logging/logRotator.d.ts +1 -1
  237. package/dist/utility/logging/logRotator.js +172 -92
  238. package/dist/utility/logging/logRotator.js.map +1 -1
  239. package/dist/utility/npmUtilities.js +6 -4
  240. package/dist/utility/npmUtilities.js.map +1 -1
  241. package/dist/utility/watcherFallback.d.ts +0 -45
  242. package/dist/utility/watcherFallback.js +1 -125
  243. package/dist/utility/watcherFallback.js.map +1 -1
  244. package/dist/validation/configValidator.js +6 -3
  245. package/dist/validation/configValidator.js.map +1 -1
  246. package/index.ts +9 -0
  247. package/json/systemSchema.json +3 -0
  248. package/npm-shrinkwrap.json +177 -87
  249. package/package.json +13 -5
  250. package/resources/DESIGN.md +689 -19
  251. package/resources/DatabaseTransaction.ts +230 -17
  252. package/resources/LMDBTransaction.ts +21 -3
  253. package/resources/PrimaryRocksDatabase.ts +20 -7
  254. package/resources/RecordEncoder.ts +52 -10
  255. package/resources/Resource.ts +97 -13
  256. package/resources/ResourceInterface.ts +8 -0
  257. package/resources/RocksIndexStore.ts +2 -1
  258. package/resources/RocksTransactionLogStore.ts +111 -31
  259. package/resources/Table.ts +1273 -396
  260. package/resources/analytics/DESIGN.md +38 -0
  261. package/resources/analytics/write.ts +50 -17
  262. package/resources/auditStore.ts +460 -11
  263. package/resources/crdt.ts +22 -0
  264. package/resources/dataLoader.ts +3 -4
  265. package/resources/databases.ts +611 -147
  266. package/resources/derivedIndexRegistry.ts +56 -0
  267. package/resources/derivedIndexRuntime.ts +2292 -0
  268. package/resources/graphql.ts +3 -2
  269. package/resources/indexes/DESIGN.md +833 -0
  270. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
  271. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  272. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  273. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  274. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  275. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  276. package/resources/nodeIdMapping.ts +50 -0
  277. package/resources/record-locks.md +1407 -0
  278. package/resources/recordLock.ts +173 -7
  279. package/resources/recordLockCoordinator.ts +3082 -0
  280. package/resources/replayLogs.ts +5 -0
  281. package/resources/replicatedApplyFailure.ts +77 -0
  282. package/resources/scheduler/DESIGN.md +40 -0
  283. package/resources/scheduler/scheduler.ts +4 -4
  284. package/resources/search.ts +169 -49
  285. package/resources/tracked.ts +73 -22
  286. package/resources/transactionBroadcast.ts +4 -4
  287. package/security/DESIGN.md +175 -0
  288. package/security/auth.ts +53 -24
  289. package/security/deferredAuthentication.ts +24 -2
  290. package/security/jsLoader.ts +9 -4
  291. package/server/DESIGN.md +275 -0
  292. package/server/REST.ts +42 -6
  293. package/server/http.ts +34 -2
  294. package/server/mqtt.ts +6 -4
  295. package/server/serverHelpers/Headers.ts +5 -1
  296. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  297. package/server/serverHelpers/Request.ts +33 -131
  298. package/server/serverHelpers/contentTypes.ts +188 -15
  299. package/server/serverHelpers/serverUtilities.ts +157 -27
  300. package/server/serverHelpers/uwsServer.ts +4 -1
  301. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  302. package/server/storageReclamation.ts +2 -2
  303. package/server/threads/isolatedApplications.ts +157 -0
  304. package/server/threads/logRotationTransport.ts +40 -0
  305. package/server/threads/manageThreads.js +254 -12
  306. package/server/threads/socketRouter.ts +217 -11
  307. package/server/threads/threadServer.js +30 -7
  308. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
  309. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
  310. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
  311. package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
  312. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
  313. package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
  314. package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
  315. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
  316. package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
  317. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
  318. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
  319. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
  320. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
  321. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  322. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
  323. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
  324. package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
  325. package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
  326. package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
  327. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
  328. package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
  329. package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
  330. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  331. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
  332. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
  333. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  334. package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
  335. package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
  336. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
  337. package/studio/web/index.html +1 -1
  338. package/utility/DESIGN.md +55 -0
  339. package/utility/errors/hdbError.ts +54 -0
  340. package/utility/hdbTerms.ts +2 -0
  341. package/utility/logging/harper_logger.ts +210 -30
  342. package/utility/logging/logGenerationCoordinator.ts +196 -0
  343. package/utility/logging/logRotation.ts +367 -0
  344. package/utility/logging/logRotator.ts +196 -91
  345. package/utility/npmUtilities.ts +6 -4
  346. package/utility/watcherFallback.ts +0 -122
  347. package/validation/configValidator.ts +6 -3
@@ -0,0 +1,2292 @@
1
+ import type { RocksDatabase } from '@harperfast/rocksdb-js';
2
+ import type { Id } from './ResourceInterface.ts';
3
+ import type { AuditRecord } from './auditStore.ts';
4
+ import type { RocksTransactionLogStore, TransactionLogIterable } from './RocksTransactionLogStore.ts';
5
+ import { writeKeyId } from './DatabaseTransaction.ts';
6
+ import { registerDerivedIndexTables } from './derivedIndexRegistry.ts';
7
+ import { loggerWithTag } from '../utility/logging/logger.ts';
8
+ import { DerivedIndexLagError, ServerError } from '../utility/errors/hdbError.ts';
9
+
10
+ const logger = loggerWithTag('derived-index');
11
+
12
+ export const DERIVED_INDEX_ACCEPTED = 1;
13
+ export const DERIVED_INDEX_DEFERRED = 0;
14
+ export const DERIVED_INDEX_FAILED = -1;
15
+
16
+ export type DerivedIndexDeliveryResult =
17
+ typeof DERIVED_INDEX_ACCEPTED | typeof DERIVED_INDEX_DEFERRED | typeof DERIVED_INDEX_FAILED;
18
+
19
+ export type DerivedIndexCursor = {
20
+ format: 1;
21
+ logs: Record<string, number>;
22
+ coverage?: DerivedIndexPositions;
23
+ };
24
+
25
+ export type DerivedIndexPositions = Record<string, { sequence: number; offset: number } | null>;
26
+
27
+ export type DerivedIndexCoverage = {
28
+ state: 'current' | 'bounded' | 'unknown';
29
+ maxLagMilliseconds: number;
30
+ lagUpperBoundMilliseconds?: number;
31
+ };
32
+
33
+ export function derivedIndexTime(store: RocksDatabase): bigint {
34
+ // Bun's hrtime origin is worker-local; the transaction clock is shared by every worker.
35
+ let milliseconds: number;
36
+ try {
37
+ milliseconds = store.getMonotonicTimestamp();
38
+ } catch {
39
+ throw new ServerError('The derived index coverage clock is unavailable', 503);
40
+ }
41
+ const whole = Math.trunc(milliseconds);
42
+ return BigInt(whole) * 1_000_000n + BigInt(Math.round((milliseconds - whole) * 1_000_000));
43
+ }
44
+
45
+ type CoverageCapture = { time: bigint; positions: DerivedIndexPositions };
46
+ type CoverageWaiter = { since: bigint; deadline: bigint; finish: (error?: unknown) => void };
47
+ type CoverageWaitGroup = { waiters: Set<CoverageWaiter>; timer?: NodeJS.Timeout };
48
+
49
+ export type DerivedIndexState =
50
+ | { kind: 'record'; version: number; projection: unknown }
51
+ | { kind: 'absent' }
52
+ /** The projection rejected the record (a 4xx-classified error); the backend removes any entry and counts it. */
53
+ | { kind: 'unindexable'; version: number; reason: string };
54
+
55
+ export type DerivedIndexMutation = {
56
+ tableId: number;
57
+ recordId: Id;
58
+ logVersion: number;
59
+ state: DerivedIndexState;
60
+ };
61
+
62
+ export type DerivedIndexTransaction = {
63
+ logName: string;
64
+ timestamp: number;
65
+ mutations: DerivedIndexMutation[];
66
+ };
67
+
68
+ export type DerivedIndexBatch = {
69
+ ownerEpoch: bigint;
70
+ transactions: DerivedIndexTransaction[];
71
+ /**
72
+ * Last-write-wins view over the distinct `(tableId, writeKeyId(recordId))` keys of the batch, in
73
+ * first-occurrence order, each carrying the last `logVersion` and the same resolved `state`
74
+ * object as its occurrences in `transactions`.
75
+ */
76
+ records: DerivedIndexMutation[];
77
+ /**
78
+ * Cursor vector this batch completes. Absent on a rebuild scan chunk: such a batch advances no
79
+ * cursor and the backend's durable cursor must stay `undefined` until a batch carrying `through`
80
+ * has been made durable.
81
+ */
82
+ through?: DerivedIndexCursor;
83
+ bytes: number;
84
+ rebuild?: true;
85
+ };
86
+
87
+ export type DerivedIndexFlushReason = 'age' | 'threshold' | 'shutdown';
88
+
89
+ export type DerivedIndexReadinessState = 'unknown' | 'ready' | 'rebuilding' | 'needs-rebuild' | 'unavailable';
90
+
91
+ /**
92
+ * Why an index is not `ready`, as a code every worker can read from shared memory. The owner's log
93
+ * line carries the full message; the code is what a peer or a successor can act on.
94
+ */
95
+ export type DerivedIndexReadinessReason =
96
+ | 'none'
97
+ | 'cursor-missing'
98
+ | 'cursor-unoffered'
99
+ | 'log-missing'
100
+ | 'log-retention'
101
+ | 'log-corrupt'
102
+ | 'reload'
103
+ | 'backend-failed'
104
+ | 'runner-failed'
105
+ | 'condemned'
106
+ | 'shutdown-failed'
107
+ | 'rebuild-failed'
108
+ | 'rebuild-exhausted'
109
+ | 'rebuild-requested';
110
+
111
+ export type DerivedIndexReadiness = {
112
+ state: DerivedIndexReadinessState;
113
+ reason?: DerivedIndexReadinessReason;
114
+ /** The most recently minted owner epoch; a backend fences queued work against it through `isOwnerEpoch`. */
115
+ ownerEpoch: bigint;
116
+ rebuildAttempts: number;
117
+ };
118
+
119
+ export interface DerivedIndexBackendHost {
120
+ /** True while `epoch` is the most recently minted owner epoch for this backend. */
121
+ isOwnerEpoch(epoch: bigint): boolean;
122
+ getReadiness(): DerivedIndexReadiness;
123
+ }
124
+
125
+ /**
126
+ * A backend queues expensive work and publishes durability later: `deliver()` may only enqueue, a
127
+ * barrier completes asynchronously, and the durable cursor trails delivery. Work that survives a
128
+ * method return is the safety boundary, so every backend provides the epoch fence, the barrier
129
+ * request and the quiescence handshake the handoff protocol needs, even one that happens to complete
130
+ * everything before returning.
131
+ */
132
+ export interface DerivedIndexBackend {
133
+ readonly id: string;
134
+ /** Receives the epoch fence and readiness reader before any delivery. */
135
+ attach(host: DerivedIndexBackendHost): void;
136
+ getDurableCursor(): DerivedIndexCursor | undefined;
137
+ /** Persist coverage only after its offered cursor is durable; reset must remove it with the cursor. */
138
+ publishCoverage?(positions: DerivedIndexPositions, ownerEpoch: bigint): void;
139
+ deliver(batch: DerivedIndexBatch): DerivedIndexDeliveryResult;
140
+ /** Request a durability barrier; the backend completes it and wakes through `onStateChange`. */
141
+ flush(reason: DerivedIndexFlushReason): void | Promise<void>;
142
+ /**
143
+ * Stop accepting work for `ownerEpoch`, settle or discard what is queued, and resolve once nothing
144
+ * further will be applied or published for it. A rejection keeps the runner lock held.
145
+ */
146
+ shutdown(ownerEpoch: bigint): void | Promise<void>;
147
+ onStateChange(wake: (change?: DerivedIndexBackendStateChange) => void): () => void;
148
+ /**
149
+ * Destroy index state and the durable cursor; `getDurableCursor()` must return `undefined`
150
+ * afterwards. Crash safety is the backend's: its first durable action must invalidate the cursor
151
+ * (or the generation the cursor belongs to) before anything destructive, so an interrupted reset
152
+ * reopens as cursorless rather than as a valid cursor over partially destroyed state. Shared
153
+ * readiness is process memory and is no evidence after a restart.
154
+ */
155
+ reset?(ownerEpoch: bigint): void | Promise<void>;
156
+ }
157
+
158
+ export type DerivedIndexBackendStateChange = 'changed' | 'accepted-work-lost' | 'failed';
159
+
160
+ export type DerivedIndexRunnerOptions = {
161
+ maxTransactionsPerTurn?: number;
162
+ maxBytesPerTurn?: number;
163
+ maxMillisecondsPerTurn?: number;
164
+ /** Hard bound on distinct records resolved per chunk; an oversized transaction is cut here. */
165
+ maxChunkRecords?: number;
166
+ /** Estimated payload bytes after which a chunk stops adding complete transactions. */
167
+ maxChunkBytes?: number;
168
+ maxAcceptedBatchesAhead?: number;
169
+ maxFlushAgeMilliseconds?: number;
170
+ flushAfterMutations?: number;
171
+ flushAfterBytes?: number;
172
+ rebuildBackoffMilliseconds?: number;
173
+ maxRebuildBackoffMilliseconds?: number;
174
+ maxRebuildAttempts?: number;
175
+ /**
176
+ * Cadence on which a non-owner re-tries the runner lock when no wake reaches it: the fallback for
177
+ * an owner that died without releasing, whose lock is released natively with nothing to notify.
178
+ */
179
+ lockRetryMilliseconds?: number;
180
+ /**
181
+ * Opt-in writer backpressure: while the index is further behind than this, user writes to its
182
+ * tables fail with a retryable 503 on every worker. 0 (the default) means no policy.
183
+ */
184
+ maxLagMilliseconds?: number;
185
+ };
186
+
187
+ export type DerivedIndexRegistration = {
188
+ backend: DerivedIndexBackend;
189
+ projections: ReadonlyMap<number, (record: unknown) => unknown>;
190
+ options?: DerivedIndexRunnerOptions;
191
+ };
192
+
193
+ /**
194
+ * `size` is the stored byte size of the record when known; it bounds the projection's size without
195
+ * serializing it. Resolve a missing, deleted or evicted record as `undefined`; a present entry is
196
+ * projected as-is, so an undecodable body fails closed instead of silently leaving the index.
197
+ */
198
+ export type DerivedIndexRecord = { version: number; value: unknown; size?: number } | undefined;
199
+
200
+ /** A scan record whose `value` is null or undefined is a tombstone, and one whose `recordId` is a symbol is a Harper-internal store entry; neither is indexed. */
201
+ export type DerivedIndexScanRecord = { recordId: Id; version: number; value: unknown; size?: number };
202
+
203
+ export type DerivedIndexRuntimeOptions = DerivedIndexRunnerOptions & {
204
+ idleGraceMilliseconds?: number;
205
+ now?: () => number;
206
+ /** Iterates every current record of a table for the rebuild scan; without it a rebuild cannot run. */
207
+ scanRecords?: (tableId: number) => Iterable<DerivedIndexScanRecord>;
208
+ };
209
+
210
+ export type DerivedIndexRunnerStatus =
211
+ | {
212
+ state: 'idle' | 'running' | 'deferred' | 'waiting-durable' | 'stopped' | 'rebuilding';
213
+ ownerEpoch?: bigint;
214
+ }
215
+ | { state: 'needs-rebuild' | 'unavailable'; reason: string; ownerEpoch?: bigint };
216
+
217
+ export type DerivedIndexRunnerMetrics = {
218
+ readiness: DerivedIndexReadiness;
219
+ acceptedBatches: number;
220
+ acceptedBytes: number;
221
+ acceptedMutations: number;
222
+ deferredBytes: number;
223
+ oldestAcceptedAgeMilliseconds: number;
224
+ /** Lag between the latest transaction this runner has read and the durable cursor; blind while parked. */
225
+ cursorLagMilliseconds: number;
226
+ /** How long the runner has been parked on backend backpressure or the durability ceiling. */
227
+ stalledMilliseconds: number;
228
+ unindexableRecords: number;
229
+ rebuildAttempts: number;
230
+ rebuiltRecords: number;
231
+ /** How long the current epoch's quiescence (backend shutdown) has been pending. */
232
+ quiescenceAgeMilliseconds: number;
233
+ };
234
+
235
+ type ResolvedRunnerOptions = Required<DerivedIndexRunnerOptions> & {
236
+ idleGraceMilliseconds: number;
237
+ now: () => number;
238
+ };
239
+
240
+ const ELIGIBLE_ACTIONS = new Set(['put', 'patch', 'delete', 'invalidate', 'relocate', 'evict']);
241
+
242
+ const READINESS_STATES: DerivedIndexReadinessState[] = [
243
+ 'unknown',
244
+ 'ready',
245
+ 'rebuilding',
246
+ 'needs-rebuild',
247
+ 'unavailable',
248
+ ];
249
+ const READINESS_REASONS: DerivedIndexReadinessReason[] = [
250
+ 'none',
251
+ 'cursor-missing',
252
+ 'cursor-unoffered',
253
+ 'log-missing',
254
+ 'log-retention',
255
+ 'log-corrupt',
256
+ 'reload',
257
+ 'backend-failed',
258
+ 'runner-failed',
259
+ 'condemned',
260
+ 'shutdown-failed',
261
+ 'rebuild-failed',
262
+ 'rebuild-exhausted',
263
+ 'rebuild-requested',
264
+ ];
265
+ const CONDEMNED_MARKER = new Uint8Array([1]);
266
+ // One shared allocation per backend: five independently read Int32 words, then the owner-epoch
267
+ // counter. Each word is self-consistent on its own; nothing needs to observe two of them atomically.
268
+ const READINESS_WORDS = 6;
269
+ const READINESS_EPOCH_OFFSET = READINESS_WORDS * 4;
270
+ const READINESS_COVERAGE_OFFSET = READINESS_EPOCH_OFFSET + 8;
271
+ export const READINESS_BYTES = READINESS_COVERAGE_OFFSET + 8;
272
+ const READINESS_STATE = 0;
273
+ const READINESS_REASON = 1;
274
+ const READINESS_ATTEMPTS = 2;
275
+ const READINESS_REBUILD_REQUEST = 3;
276
+ const READINESS_LAG_EXCEEDED = 4;
277
+
278
+ /** A failure raised inside the collector that already knows its shareable reason. */
279
+ class RunnerError extends Error {
280
+ code: DerivedIndexReadinessReason;
281
+ constructor(code: DerivedIndexReadinessReason, message: string) {
282
+ super(message);
283
+ this.code = code;
284
+ }
285
+ }
286
+
287
+ export class DerivedIndexRuntime {
288
+ #logStore: RocksTransactionLogStore;
289
+ #resolveRecord: (tableId: number, recordId: Id) => DerivedIndexRecord;
290
+ #scanRecords?: (tableId: number) => Iterable<DerivedIndexScanRecord>;
291
+ #options: ResolvedRunnerOptions;
292
+ #runners = new Map<string, DerivedIndexRunner>();
293
+ #pendingStops = new Set<Promise<void>>();
294
+ #heldRunners = new Map<string, { runner: DerivedIndexRunner; stopped: Promise<void> }>();
295
+ #stopping?: Promise<void>;
296
+ #onCommit = () => this.wake();
297
+ #listening = false;
298
+ #stopped = false;
299
+ #coverageWaits = new Map<string, CoverageWaitGroup>();
300
+
301
+ constructor(
302
+ logStore: RocksTransactionLogStore,
303
+ resolveRecord: (tableId: number, recordId: Id) => DerivedIndexRecord,
304
+ options: DerivedIndexRuntimeOptions = {}
305
+ ) {
306
+ this.#logStore = logStore;
307
+ this.#resolveRecord = resolveRecord;
308
+ this.#scanRecords = options.scanRecords;
309
+ this.#options = {
310
+ ...resolveRunnerOptions(options),
311
+ idleGraceMilliseconds: options.idleGraceMilliseconds ?? 30_000,
312
+ now: options.now ?? Date.now,
313
+ };
314
+ }
315
+
316
+ register(registration: DerivedIndexRegistration): () => Promise<void> {
317
+ if (this.#stopped) throw new Error('Derived index runtime is stopped');
318
+ if (!registration.backend.id) throw new Error('Derived index backend id is required');
319
+ for (const hook of ['attach', 'flush', 'shutdown'] as const) {
320
+ if (typeof registration.backend[hook] !== 'function')
321
+ throw new TypeError(`Derived index backend '${registration.backend.id}' must implement ${hook}()`);
322
+ }
323
+ if (this.#runners.has(registration.backend.id))
324
+ throw new Error(`Derived index backend '${registration.backend.id}' is already registered`);
325
+ const runner = new DerivedIndexRunner(this.#logStore, this.#resolveRecord, this.#scanRecords, registration, {
326
+ ...resolveRunnerOptions(registration.options, this.#options),
327
+ idleGraceMilliseconds: this.#options.idleGraceMilliseconds,
328
+ now: this.#options.now,
329
+ });
330
+ this.#runners.set(registration.backend.id, runner);
331
+ if (!this.#listening) {
332
+ this.#logStore.rootStore.on('committed', this.#onCommit);
333
+ this.#listening = true;
334
+ }
335
+ runner.wake(true);
336
+ return () => {
337
+ if (this.#runners.get(registration.backend.id) === runner) {
338
+ this.#runners.delete(registration.backend.id);
339
+ const waiting = this.#coverageWaits.get(registration.backend.id);
340
+ if (waiting) {
341
+ for (const waiter of waiting.waiters)
342
+ waiter.finish(new ServerError('The native HNSW index is unavailable', 503));
343
+ }
344
+ this.#stopListeningIfIdle();
345
+ }
346
+ return this.#track(runner, runner.stop());
347
+ };
348
+ }
349
+
350
+ #track(runner: DerivedIndexRunner, stopped: Promise<void>): Promise<void> {
351
+ this.#pendingStops.add(stopped);
352
+ // A failed shutdown stays pending and its runner stays reachable, so a later stop() keeps
353
+ // reporting the held lock and requestRebuild() can retry releasing it.
354
+ stopped.then(
355
+ () => this.#pendingStops.delete(stopped),
356
+ () => this.#heldRunners.set(runner.id, { runner, stopped })
357
+ );
358
+ return stopped;
359
+ }
360
+
361
+ wake() {
362
+ if (this.#stopped) return;
363
+ for (const runner of this.#runners.values()) runner.wake();
364
+ }
365
+
366
+ getStatus(backendId: string): DerivedIndexRunnerStatus | undefined {
367
+ return this.#runners.get(backendId)?.status;
368
+ }
369
+
370
+ /** Shared readiness published by whichever worker owns the index; readable on every worker. */
371
+ getReadiness(backendId: string): DerivedIndexReadiness {
372
+ return this.#runners.get(backendId)?.getReadiness() ?? readDerivedIndexReadiness(this.#logStore, backendId);
373
+ }
374
+
375
+ getMetrics(backendId: string): DerivedIndexRunnerMetrics | undefined {
376
+ return this.#runners.get(backendId)?.getMetrics();
377
+ }
378
+
379
+ waitForCoverage(backendId: string, since: bigint, timeout: number, signal?: AbortSignal): Promise<void> {
380
+ if (this.#stopped || !this.#runners.has(backendId))
381
+ return Promise.reject(new ServerError('The native HNSW index is unavailable', 503));
382
+ let group = this.#coverageWaits.get(backendId);
383
+ const first = !group;
384
+ if (!group) this.#coverageWaits.set(backendId, (group = { waiters: new Set() }));
385
+ const waiting = group;
386
+ const result = new Promise<void>((resolve, reject) => {
387
+ const waiter: CoverageWaiter = {
388
+ since,
389
+ deadline: since + BigInt(Math.ceil(timeout * 1e6)),
390
+ finish: (error) => {
391
+ if (!waiting.waiters.delete(waiter)) return;
392
+ signal?.removeEventListener('abort', abort);
393
+ if (!waiting.waiters.size) {
394
+ clearTimeout(waiting.timer);
395
+ this.#coverageWaits.delete(backendId);
396
+ }
397
+ if (error !== undefined) reject(error);
398
+ else resolve();
399
+ },
400
+ };
401
+ const abort = () => waiter.finish(signal.reason ?? new Error('Index wait aborted'));
402
+ waiting.waiters.add(waiter);
403
+ signal?.addEventListener('abort', abort, { once: true });
404
+ if (signal?.aborted) abort();
405
+ });
406
+ if (first && waiting.waiters.size) {
407
+ // Demand must not retry a deferred batch or count as unread writes for writer backpressure.
408
+ try {
409
+ this.#runners.get(backendId)?.wake(false, true);
410
+ this.#pollCoverage(backendId, waiting);
411
+ } catch (error) {
412
+ for (const waiter of waiting.waiters) waiter.finish(error);
413
+ }
414
+ }
415
+ return result;
416
+ }
417
+
418
+ #pollCoverage(backendId: string, group: CoverageWaitGroup) {
419
+ try {
420
+ const views = getReadinessViews(this.#logStore, backendId);
421
+ const before = readReadiness(views);
422
+ const time = Atomics.load(views.coverage, 0);
423
+ const after = readReadiness(views);
424
+ if (after.state !== 'ready')
425
+ throw new ServerError(
426
+ `The native HNSW index is ${after.state === 'unavailable' ? 'unavailable' : 'rebuilding'}`,
427
+ 503
428
+ );
429
+ const now = derivedIndexTime(this.#logStore.rootStore);
430
+ let delay = 25;
431
+ for (const waiter of group.waiters) {
432
+ if (before.state === 'ready' && before.ownerEpoch === after.ownerEpoch && time >= waiter.since) waiter.finish();
433
+ else if (now >= waiter.deadline)
434
+ waiter.finish(new DerivedIndexLagError('Timed out waiting for native HNSW index coverage; retry this query'));
435
+ else delay = Math.min(delay, Math.max(1, Number(waiter.deadline - now) / 1e6));
436
+ }
437
+ if (group.waiters.size) group.timer = setTimeout(() => this.#pollCoverage(backendId, group), delay);
438
+ } catch (error) {
439
+ for (const waiter of group.waiters) waiter.finish(error);
440
+ }
441
+ }
442
+
443
+ /** Force a rebuild (or retry one that became `unavailable`). Returns false when the backend cannot be rebuilt by the runtime. */
444
+ requestRebuild(backendId: string): boolean {
445
+ const held = this.#heldRunners.get(backendId);
446
+ if (held) {
447
+ const released = held.runner.retryRelease();
448
+ this.#pendingStops.add(released);
449
+ released.then(
450
+ () => {
451
+ this.#pendingStops.delete(released);
452
+ this.#pendingStops.delete(held.stopped);
453
+ if (this.#heldRunners.get(backendId) === held) this.#heldRunners.delete(backendId);
454
+ },
455
+ () => {}
456
+ );
457
+ }
458
+ const runner = this.#runners.get(backendId);
459
+ if (runner) return runner.requestRebuild();
460
+ return held !== undefined;
461
+ }
462
+
463
+ /** Resolves once every runner has released ownership and its backend shutdown has settled. */
464
+ stop(): Promise<void> {
465
+ if (this.#stopping) return this.#stopping;
466
+ this.#stopped = true;
467
+ for (const group of this.#coverageWaits.values()) {
468
+ for (const waiter of group.waiters) waiter.finish(new ServerError('The native HNSW index is unavailable', 503));
469
+ }
470
+ for (const runner of this.#runners.values()) this.#track(runner, runner.stop());
471
+ this.#runners.clear();
472
+ this.#stopListening();
473
+ this.#stopping = Promise.allSettled([...this.#pendingStops]).then((results) => {
474
+ const failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason);
475
+ if (failures.length === 1) throw failures[0];
476
+ if (failures.length) throw new AggregateError(failures, 'derived index backends failed to shut down');
477
+ });
478
+ this.#stopping.catch(() => {});
479
+ return this.#stopping;
480
+ }
481
+
482
+ #stopListeningIfIdle() {
483
+ if (this.#runners.size === 0) this.#stopListening();
484
+ }
485
+
486
+ #stopListening() {
487
+ if (!this.#listening) return;
488
+ this.#logStore.rootStore.off?.('committed', this.#onCommit);
489
+ this.#listening = false;
490
+ }
491
+ }
492
+
493
+ function resolveRunnerOptions(
494
+ options: DerivedIndexRunnerOptions | undefined,
495
+ defaults?: Required<DerivedIndexRunnerOptions>
496
+ ): Required<DerivedIndexRunnerOptions> {
497
+ const base: Required<DerivedIndexRunnerOptions> = defaults ?? {
498
+ maxTransactionsPerTurn: 256,
499
+ maxBytesPerTurn: 4 * 1024 * 1024,
500
+ maxMillisecondsPerTurn: 5,
501
+ maxChunkRecords: 4096,
502
+ maxChunkBytes: 4 * 1024 * 1024,
503
+ maxAcceptedBatchesAhead: 64,
504
+ maxFlushAgeMilliseconds: 1000,
505
+ flushAfterMutations: 4096,
506
+ flushAfterBytes: 8 * 1024 * 1024,
507
+ rebuildBackoffMilliseconds: 1000,
508
+ lockRetryMilliseconds: 5000,
509
+ maxRebuildBackoffMilliseconds: 300_000,
510
+ maxRebuildAttempts: 8,
511
+ maxLagMilliseconds: 0,
512
+ };
513
+ if (!options) return base;
514
+ return {
515
+ maxTransactionsPerTurn: options.maxTransactionsPerTurn ?? base.maxTransactionsPerTurn,
516
+ maxBytesPerTurn: options.maxBytesPerTurn ?? base.maxBytesPerTurn,
517
+ maxMillisecondsPerTurn: options.maxMillisecondsPerTurn ?? base.maxMillisecondsPerTurn,
518
+ maxChunkRecords: Math.max(1, options.maxChunkRecords ?? base.maxChunkRecords),
519
+ maxChunkBytes: options.maxChunkBytes ?? base.maxChunkBytes,
520
+ maxAcceptedBatchesAhead: Math.max(1, options.maxAcceptedBatchesAhead ?? base.maxAcceptedBatchesAhead),
521
+ maxFlushAgeMilliseconds: options.maxFlushAgeMilliseconds ?? base.maxFlushAgeMilliseconds,
522
+ flushAfterMutations: options.flushAfterMutations ?? base.flushAfterMutations,
523
+ flushAfterBytes: options.flushAfterBytes ?? base.flushAfterBytes,
524
+ rebuildBackoffMilliseconds: options.rebuildBackoffMilliseconds ?? base.rebuildBackoffMilliseconds,
525
+ lockRetryMilliseconds: options.lockRetryMilliseconds ?? base.lockRetryMilliseconds,
526
+ maxRebuildBackoffMilliseconds: options.maxRebuildBackoffMilliseconds ?? base.maxRebuildBackoffMilliseconds,
527
+ maxRebuildAttempts: options.maxRebuildAttempts ?? base.maxRebuildAttempts,
528
+ maxLagMilliseconds: Math.max(0, options.maxLagMilliseconds ?? base.maxLagMilliseconds),
529
+ };
530
+ }
531
+
532
+ /** Catch-up is proven only at a durable barrier, so a lag budget below two flush ages would trip on cadence alone. */
533
+ function effectiveLagBudget(options: Required<DerivedIndexRunnerOptions>): number {
534
+ return options.maxLagMilliseconds > 0 ? Math.max(options.maxLagMilliseconds, 2 * options.maxFlushAgeMilliseconds) : 0;
535
+ }
536
+
537
+ type OfferedProgress = {
538
+ cursor: DerivedIndexCursor;
539
+ bytes: number;
540
+ mutations: number;
541
+ acceptedAt: number;
542
+ coverage?: CoverageCapture;
543
+ };
544
+
545
+ type CollectedKey = { recordId: Id; logVersion: number; sizeHint: number | undefined };
546
+
547
+ type CollectedTransaction = {
548
+ logName: string;
549
+ timestamp: number;
550
+ keys: Map<number, Map<unknown, CollectedKey>>;
551
+ keyCount: number;
552
+ complete: boolean;
553
+ };
554
+
555
+ type Chunk = {
556
+ batch: DerivedIndexBatch;
557
+ resolved: Map<number, Map<unknown, DerivedIndexMutation>>;
558
+ started: number;
559
+ };
560
+
561
+ const CONTINUE = null;
562
+
563
+ class DerivedIndexRunner {
564
+ #logStore: RocksTransactionLogStore;
565
+ #resolveRecord: (tableId: number, recordId: Id) => DerivedIndexRecord;
566
+ #scanRecords?: (tableId: number) => Iterable<DerivedIndexScanRecord>;
567
+ #registration: DerivedIndexRegistration;
568
+ #options: ResolvedRunnerOptions;
569
+ #lockKey: string;
570
+ #markerKey: symbol;
571
+ #markersSupported: boolean;
572
+ #iterator?: Iterator<AuditRecord>;
573
+ #iterable?: TransactionLogIterable;
574
+ #knownLogs = new Set<string>();
575
+ #pendingTimestamps = new Map<string, number[]>();
576
+ #seenTimestamps = new Map<string, Set<number>>();
577
+ #offered?: DerivedIndexCursor;
578
+ #offeredCursors: OfferedProgress[] = [];
579
+ #unanchoredBytes = 0;
580
+ #unanchoredMutations = 0;
581
+ #unanchoredAcceptedAt = 0;
582
+ #pendingBatch?: DerivedIndexBatch;
583
+ #carried: CollectedTransaction[] = [];
584
+ #collectedToEnd = false;
585
+ #latestSeen = new Map<string, number>();
586
+ #stalledSince?: number;
587
+ #lastCaughtUpAt?: number;
588
+ #lagTimer?: NodeJS.Timeout;
589
+ #lockRetryTimer?: NodeJS.Timeout;
590
+ #lagBudget: number;
591
+ #scheduled = false;
592
+ #skipNextNotify = false;
593
+ #waitingForLock = false;
594
+ #lockBackoff = false;
595
+ #owned = false;
596
+ #stopped = false;
597
+ #generation = 0;
598
+ #idleTimer?: NodeJS.Timeout;
599
+ #flushTimer?: NodeJS.Timeout;
600
+ #coverageTimer?: NodeJS.Timeout;
601
+ #lastPersistedCoverageTime = 0n;
602
+ #rebuildTimer?: NodeJS.Timeout;
603
+ #unflushedBytes = 0;
604
+ #unflushedMutations = 0;
605
+ #releasing?: Promise<void>;
606
+ #releasingSince?: number;
607
+ #releaseFailure?: Error;
608
+ #stopResult?: Promise<void>;
609
+ #heldLock = false;
610
+ #quiescing?: { epoch: bigint; promise: Promise<void>; since: number };
611
+ #condemned = false;
612
+ #unreadSince?: number;
613
+ #reachedEndOfLog = false;
614
+ #rebuilding = false;
615
+ #rebuildRequested = false;
616
+ #boundaryPending = false;
617
+ #rebuildAttempts = 0;
618
+ #rebuiltRecords = 0;
619
+ #unindexableRecords = 0;
620
+ #allUnindexableWarned = false;
621
+ #rebuildWaiter?: () => void;
622
+ #rebuildWakePending = false;
623
+ #unsubscribeBackend: () => void;
624
+ #unregisterTables: () => void;
625
+ #ownerEpoch?: bigint;
626
+ #readinessBuffer: SharedReadinessBuffer;
627
+ #sharedViews: SharedViews;
628
+ #resetting?: Promise<void>;
629
+ status: DerivedIndexRunnerStatus = { state: 'idle' };
630
+
631
+ get id() {
632
+ return this.#registration.backend.id;
633
+ }
634
+
635
+ retryRelease(): Promise<void> {
636
+ if (!this.#heldLock) return this.#stopResult ?? Promise.resolve();
637
+ this.#heldLock = false;
638
+ this.#releaseFailure = undefined;
639
+ this.#owned = true;
640
+ this.#release();
641
+ this.#stopResult = (this.#releasing ?? Promise.resolve()).then(() => {
642
+ if (this.#releaseFailure) throw this.#releaseFailure;
643
+ this.#unregisterTables();
644
+ });
645
+ this.#stopResult.catch(() => {});
646
+ return this.#stopResult;
647
+ }
648
+
649
+ constructor(
650
+ logStore: RocksTransactionLogStore,
651
+ resolveRecord: (tableId: number, recordId: Id) => DerivedIndexRecord,
652
+ scanRecords: ((tableId: number) => Iterable<DerivedIndexScanRecord>) | undefined,
653
+ registration: DerivedIndexRegistration,
654
+ options: ResolvedRunnerOptions
655
+ ) {
656
+ this.#logStore = logStore;
657
+ this.#resolveRecord = resolveRecord;
658
+ this.#scanRecords = scanRecords;
659
+ this.#registration = registration;
660
+ this.#options = options;
661
+ this.#lockKey = `derived-index:${registration.backend.id}:runner`;
662
+ this.#markerKey = Symbol.for(`derived-index:${registration.backend.id}:condemned`);
663
+ const root = logStore.rootStore as { getSync?: unknown; removeSync?: unknown } | undefined;
664
+ this.#markersSupported =
665
+ typeof logStore.putSync === 'function' &&
666
+ typeof root?.getSync === 'function' &&
667
+ typeof root?.removeSync === 'function';
668
+ this.#lagBudget = effectiveLagBudget(options);
669
+ this.#readinessBuffer = readinessBuffer(logStore, registration.backend.id, () => this.#notified());
670
+ this.#sharedViews = sharedViewsOf(this.#readinessBuffer);
671
+ try {
672
+ registration.backend.attach({
673
+ isOwnerEpoch: (epoch) => Atomics.load(this.#sharedViews.epoch, 0) === epoch,
674
+ getReadiness: () => this.getReadiness(),
675
+ });
676
+ this.#unsubscribeBackend = registration.backend.onStateChange((change = 'changed') =>
677
+ this.#backendStateChanged(change)
678
+ );
679
+ } catch (error) {
680
+ this.#readinessBuffer.cancel?.();
681
+ throw error;
682
+ }
683
+ this.#unregisterTables = registerDerivedIndexTables(
684
+ logStore,
685
+ registration.projections.keys(),
686
+ this.#lagBudget > 0 ? () => this.#writeRejection() : undefined
687
+ );
688
+ }
689
+
690
+ /**
691
+ * rocksdb-js wraps one process-wide native allocation per key in a new external ArrayBuffer on every
692
+ * call and never re-seeds an existing entry, so each view is fetched once and held for the runner's
693
+ * life; the held wrapper keeps the allocation alive.
694
+ */
695
+ #shared(): SharedViews {
696
+ return this.#sharedViews;
697
+ }
698
+
699
+ wake(fromBackend = false, forCoverage = false) {
700
+ if (this.#stopped || this.#rebuilding) return;
701
+ if (!fromBackend && !forCoverage && this.#lagBudget > 0) this.#unreadSince ??= this.#options.now();
702
+ if (this.status.state === 'unavailable') {
703
+ if (
704
+ this.#heldLock ||
705
+ Atomics.load(this.#shared().words, READINESS_STATE) === READINESS_STATES.indexOf('unavailable')
706
+ )
707
+ return;
708
+ this.status = { state: 'idle' };
709
+ }
710
+ // A shared rebuild request must reach an owner parked on backpressure or backoff at its next wake.
711
+ const requested = Atomics.load(this.#shared().words, READINESS_REBUILD_REQUEST) === 1;
712
+ if (!requested) {
713
+ if (this.status.state === 'needs-rebuild' && (this.#rebuildTimer || !this.#rebuildRequested)) return;
714
+ if (!fromBackend && (this.status.state === 'deferred' || this.status.state === 'waiting-durable')) return;
715
+ }
716
+ if (this.#idleTimer) {
717
+ clearTimeout(this.#idleTimer);
718
+ this.#idleTimer = undefined;
719
+ }
720
+ if (this.#scheduled) return;
721
+ this.#scheduled = true;
722
+ setImmediate(() => {
723
+ this.#scheduled = false;
724
+ if (this.#stopped) return;
725
+ if (this.#owned) this.#drain();
726
+ else this.#acquire();
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Rejects when the backend could not prove its queued work quiescent; the runner lock stays held
732
+ * then. Callers await it before closing storage: it resolves only once nothing can still write.
733
+ */
734
+ stop(): Promise<void> {
735
+ if (this.#stopResult) return this.#stopResult;
736
+ this.#stopped = true;
737
+ this.status = { state: 'stopped', ownerEpoch: this.#ownerEpoch };
738
+ if (this.#idleTimer) clearTimeout(this.#idleTimer);
739
+ if (this.#rebuildTimer) clearTimeout(this.#rebuildTimer);
740
+ if (this.#lockRetryTimer) clearTimeout(this.#lockRetryTimer);
741
+ this.#rebuildTimer = undefined;
742
+ try {
743
+ this.#unsubscribeBackend?.();
744
+ this.#readinessBuffer.cancel?.();
745
+ } catch (error) {
746
+ logger.warn?.(`Derived index '${this.id}' cleanup hook threw`, error);
747
+ }
748
+ this.#release();
749
+ this.#stopResult = (this.#releasing ?? Promise.resolve()).then(() => {
750
+ if (this.#releaseFailure) throw this.#releaseFailure;
751
+ this.#unregisterTables();
752
+ });
753
+ this.#stopResult.catch(() => {});
754
+ return this.#stopResult;
755
+ }
756
+
757
+ getReadiness(): DerivedIndexReadiness {
758
+ return readReadiness(this.#shared());
759
+ }
760
+
761
+ getMetrics(): DerivedIndexRunnerMetrics {
762
+ const now = this.#options.now();
763
+ let acceptedBytes = this.#unanchoredBytes;
764
+ let acceptedMutations = this.#unanchoredMutations;
765
+ const oldestAcceptedAt = this.#oldestAcceptedAt();
766
+ for (let i = 1; i < this.#offeredCursors.length; i++) {
767
+ acceptedBytes += this.#offeredCursors[i].bytes;
768
+ acceptedMutations += this.#offeredCursors[i].mutations;
769
+ }
770
+ const cursorLag = this.#cursorLag();
771
+ return {
772
+ readiness: this.getReadiness(),
773
+ acceptedBatches: Math.max(0, this.#offeredCursors.length - 1),
774
+ acceptedBytes,
775
+ acceptedMutations,
776
+ deferredBytes: this.#pendingBatch?.bytes ?? 0,
777
+ oldestAcceptedAgeMilliseconds: oldestAcceptedAt === undefined ? 0 : Math.max(0, now - oldestAcceptedAt),
778
+ cursorLagMilliseconds: cursorLag,
779
+ stalledMilliseconds: this.#stalledSince === undefined ? 0 : Math.max(0, now - this.#stalledSince),
780
+ unindexableRecords: this.#unindexableRecords,
781
+ rebuildAttempts: this.#rebuildAttempts,
782
+ rebuiltRecords: this.#rebuiltRecords,
783
+ quiescenceAgeMilliseconds:
784
+ this.#releasingSince === undefined && this.#quiescing === undefined
785
+ ? 0
786
+ : Math.max(0, now - Math.min(this.#releasingSince ?? Infinity, this.#quiescing?.since ?? Infinity)),
787
+ };
788
+ }
789
+
790
+ #cursorLag(): number {
791
+ let cursorLag = 0;
792
+ const durable = this.#offeredCursors[0]?.cursor;
793
+ if (durable) {
794
+ for (const [logName, latest] of this.#latestSeen) {
795
+ const position = durable.logs[logName];
796
+ if (position !== undefined && latest > position) cursorLag = Math.max(cursorLag, latest - position);
797
+ }
798
+ }
799
+ return cursorLag;
800
+ }
801
+
802
+ #writeRejection(): string | undefined {
803
+ if (Atomics.load(this.#shared().words, READINESS_LAG_EXCEEDED) !== 1) return;
804
+ return `derived index '${this.id}' is more than ${this.#lagBudget} ms behind; retry this write`;
805
+ }
806
+
807
+ /**
808
+ * Time since the oldest commit this runner may not have read, bounded by how far the newest
809
+ * entry it has read trails the clock: a reader that never quite empties a steadily fed log is
810
+ * behind by that distance, not by the age of its first unread commit.
811
+ */
812
+ #unreadAge(now: number): number {
813
+ if (this.#unreadSince === undefined) return 0;
814
+ let newestRead = -Infinity;
815
+ for (const latest of this.#latestSeen.values()) if (latest > newestRead) newestRead = latest;
816
+ return Math.max(0, Math.min(now - this.#unreadSince, now - newestRead));
817
+ }
818
+
819
+ #oldestAcceptedAt(): number | undefined {
820
+ if (this.#offeredCursors.length > 1) return this.#offeredCursors[1].acceptedAt;
821
+ return this.#unanchoredMutations > 0 ? this.#unanchoredAcceptedAt : undefined;
822
+ }
823
+
824
+ /**
825
+ * Owner-only. Lag is the longest of four terms: cursor distance behind what this runner has read,
826
+ * time parked on backpressure, time since the oldest commit this runner may not have read yet (a
827
+ * reader too slow to reach the end of the log cannot hide), and the age of the oldest accepted
828
+ * work not yet durable (a backend that accepts but never barriers cannot hide). All four are zero
829
+ * for a caught-up owner sitting idle and stay within drain latency plus flush age for a runner
830
+ * keeping up under sustained ingest. The trip survives discard and handoff: a successor clears it
831
+ * only after proving catch-up itself — a durable advance and the end of the log both reached since
832
+ * it acquired, so an inherited backlog cannot be cleared by one barrier — below half the budget.
833
+ */
834
+ #publishLag() {
835
+ const max = this.#lagBudget;
836
+ if (max <= 0 || !this.#owned || this.#rebuilding) return;
837
+ const now = this.#options.now();
838
+ const oldestAccepted = this.#oldestAcceptedAt();
839
+ const lag = Math.max(
840
+ this.#cursorLag(),
841
+ this.#stalledSince === undefined ? 0 : now - this.#stalledSince,
842
+ this.#unreadAge(now),
843
+ oldestAccepted === undefined ? 0 : now - oldestAccepted
844
+ );
845
+ const words = this.#shared().words;
846
+ const tripped = Atomics.load(words, READINESS_LAG_EXCEEDED) === 1;
847
+ if (!tripped && lag >= max) {
848
+ Atomics.store(words, READINESS_LAG_EXCEEDED, 1);
849
+ logger.warn?.(`Derived index '${this.id}' is ${Math.round(lag)} ms behind; rejecting writes until it catches up`);
850
+ } else if (tripped && lag < max / 2 && this.#lastCaughtUpAt !== undefined && this.#reachedEndOfLog) {
851
+ Atomics.store(words, READINESS_LAG_EXCEEDED, 0);
852
+ logger.info?.(`Derived index '${this.id}' caught up; admitting writes again`);
853
+ }
854
+ if (this.#lagTimer) return;
855
+ this.#lagTimer = setTimeout(
856
+ () => {
857
+ this.#lagTimer = undefined;
858
+ if (this.#owned && !this.#stopped) this.#publishLag();
859
+ },
860
+ Math.min(1000, Math.max(1, max / 4))
861
+ );
862
+ this.#lagTimer.unref?.();
863
+ }
864
+
865
+ requestRebuild(): boolean {
866
+ if (this.#stopped || !this.#canRebuild()) return false;
867
+ if (this.#rebuilding) return true;
868
+ this.#rebuildAttempts = 0;
869
+ if (this.#rebuildTimer) {
870
+ clearTimeout(this.#rebuildTimer);
871
+ this.#rebuildTimer = undefined;
872
+ }
873
+ if (this.status.state === 'unavailable') {
874
+ this.status = { state: 'needs-rebuild', reason: this.status.reason, ownerEpoch: this.#ownerEpoch };
875
+ }
876
+ if (this.#owned) {
877
+ this.#startRebuild();
878
+ return true;
879
+ }
880
+ if (this.#heldLock) {
881
+ this.#rebuildRequested = true;
882
+ this.#acquired(true);
883
+ return true;
884
+ }
885
+ // The owner may be another worker that never idles: leave the request where every runner looks,
886
+ // and notify whoever holds the buffer's callback.
887
+ Atomics.store(this.#shared().words, READINESS_REBUILD_REQUEST, 1);
888
+ this.#readinessBuffer.notify?.();
889
+ this.wake(true);
890
+ return true;
891
+ }
892
+
893
+ #takeSharedRebuildRequest(): boolean {
894
+ return Atomics.exchange(this.#shared().words, READINESS_REBUILD_REQUEST, 0) === 1;
895
+ }
896
+
897
+ #canRebuild(): boolean {
898
+ return typeof this.#registration.backend.reset === 'function' && this.#scanRecords !== undefined;
899
+ }
900
+
901
+ /**
902
+ * `notify()` reaches every registration for the key including the caller's own, exactly once
903
+ * (measured on the pinned rocksdb-js 2.9.0), which is what lets the releasing runner consume the
904
+ * notification it caused instead of re-acquiring the lock it just gave up. #2576 reverts to the
905
+ * unlock callback and can drop the skip with it.
906
+ *
907
+ * Only an owner (which the shared rebuild request above is aimed at) and a runner parked on the
908
+ * lock act on it. A dormant ex-owner must not: it would take the idling peer's release, find no
909
+ * work, idle out and hand it back, rotating ownership — and a backend shutdown and a fresh owner
910
+ * epoch with it — every grace period on a system with nothing to do.
911
+ */
912
+ #notified() {
913
+ if (this.#skipNextNotify) {
914
+ this.#skipNextNotify = false;
915
+ return;
916
+ }
917
+ if (!this.#owned && !this.#waitingForLock) return;
918
+ this.#waitingForLock = false;
919
+ try {
920
+ this.wake(true);
921
+ } catch (error) {
922
+ this.#fail('readiness notification failed', error);
923
+ }
924
+ }
925
+
926
+ /**
927
+ * The lock is taken without an unlock callback. rocksdb-js queues such a callback as a
928
+ * thread-safe function of the caller's env, and on Node 22 a callback left behind by a worker that
929
+ * was terminated aborts the process when another thread unlocks (HarperFast/rocksdb-js, pending).
930
+ * A successor is woken by the releasing owner's `notify()` on the readiness buffer — which
931
+ * tolerates a dead listener's env — and by the retry timer below.
932
+ */
933
+ #acquire() {
934
+ // Commit wakes are frequent; neither a parked nor a backing-off runner may re-probe on each one.
935
+ if (this.#waitingForLock || this.#lockBackoff) return;
936
+ if (this.#releasing) {
937
+ this.#releasing.then(() => this.wake(true));
938
+ return;
939
+ }
940
+ let acquired: boolean;
941
+ try {
942
+ acquired = this.#logStore.tryLock(this.#lockKey);
943
+ } catch (error) {
944
+ logger.error(`Derived index '${this.id}' could not attempt the runner lock; retrying`, error);
945
+ this.#lockBackoff = true;
946
+ // Replace an armed contention retry rather than inheriting its deadline.
947
+ clearTimeout(this.#lockRetryTimer);
948
+ this.#lockRetryTimer = undefined;
949
+ this.#armLockRetry(this.#options.rebuildBackoffMilliseconds);
950
+ return;
951
+ }
952
+ if (!acquired) {
953
+ this.#waitingForLock = true;
954
+ this.#armLockRetry(this.#options.lockRetryMilliseconds);
955
+ return;
956
+ }
957
+ if (this.#lockRetryTimer) {
958
+ clearTimeout(this.#lockRetryTimer);
959
+ this.#lockRetryTimer = undefined;
960
+ }
961
+ this.#acquired();
962
+ }
963
+
964
+ #armLockRetry(delay: number) {
965
+ if (this.#lockRetryTimer) return;
966
+ this.#lockRetryTimer = setTimeout(() => {
967
+ this.#lockRetryTimer = undefined;
968
+ this.#lockBackoff = false;
969
+ this.#waitingForLock = false;
970
+ this.wake(true);
971
+ }, delay);
972
+ this.#lockRetryTimer.unref?.();
973
+ }
974
+
975
+ #acquired(reviving = false) {
976
+ this.#heldLock = false;
977
+ this.#releaseFailure = undefined;
978
+ this.#owned = true;
979
+ this.#generation++;
980
+ this.#lastCaughtUpAt = undefined;
981
+ this.#unreadSince = this.#options.now();
982
+ this.#reachedEndOfLog = false;
983
+ try {
984
+ if (!reviving) this.#ownerEpoch = this.#mintEpoch();
985
+ this.status = { state: 'running', ownerEpoch: this.#ownerEpoch };
986
+ const condemned = this.#readCondemnation();
987
+ const shared = this.getReadiness();
988
+ if (this.#takeSharedRebuildRequest()) {
989
+ this.#rebuildRequested = true;
990
+ this.#rebuildAttempts = 0;
991
+ } else if (!this.#rebuildRequested) this.#rebuildAttempts = shared.rebuildAttempts;
992
+ if (this.#rebuildRequested && this.#canRebuild()) {
993
+ this.#startRebuild();
994
+ return;
995
+ }
996
+ if (shared.state === 'unavailable') {
997
+ this.status = {
998
+ state: 'unavailable',
999
+ reason: `unavailable by a previous owner (${shared.reason ?? 'none'})`,
1000
+ ownerEpoch: this.#ownerEpoch,
1001
+ };
1002
+ this.#admitWrites();
1003
+ this.#release();
1004
+ return;
1005
+ }
1006
+ if (shared.state === 'needs-rebuild' || shared.state === 'rebuilding') {
1007
+ if (this.#canRebuild()) this.#startRebuild();
1008
+ else {
1009
+ if (this.#writeCondemnation()) this.#rebuildRequested = false;
1010
+ this.status = {
1011
+ state: 'needs-rebuild',
1012
+ reason: `condemned by a previous owner (${shared.reason ?? 'none'})`,
1013
+ ownerEpoch: this.#ownerEpoch,
1014
+ };
1015
+ this.#admitWrites();
1016
+ this.#release();
1017
+ }
1018
+ return;
1019
+ }
1020
+ if (condemned) {
1021
+ this.#needsRebuild('condemned before a restart; the durable cursor is not trusted', 'condemned');
1022
+ return;
1023
+ }
1024
+ this.#resetFromDurableCursor();
1025
+ if (this.#owned && !this.#rebuilding) this.#drain();
1026
+ } catch (error) {
1027
+ this.#fail('failed to initialize the runner', error);
1028
+ }
1029
+ }
1030
+
1031
+ #mintEpoch(): bigint {
1032
+ return Atomics.add(this.#sharedViews.epoch, 0, 1n) + 1n;
1033
+ }
1034
+
1035
+ #resetFromDurableCursor() {
1036
+ const durable = this.#registration.backend.getDurableCursor();
1037
+ if (!isValidCursor(durable)) {
1038
+ this.#needsRebuild(
1039
+ durable ? 'backend returned an invalid durable cursor' : 'backend has no durable cursor',
1040
+ 'cursor-missing'
1041
+ );
1042
+ return;
1043
+ }
1044
+ if (!this.#installCursor(durable)) return;
1045
+ this.#publishReadiness('ready');
1046
+ }
1047
+
1048
+ #installCursor(cursor: DerivedIndexCursor): boolean {
1049
+ this.#validateLogSet(cursor);
1050
+ if (!this.#owned || this.status.state === 'needs-rebuild' || this.status.state === 'unavailable') return false;
1051
+ this.#offered = cloneCursor(cursor);
1052
+ this.#offeredCursors = [{ cursor: cloneCursor(cursor), bytes: 0, mutations: 0, acceptedAt: this.#options.now() }];
1053
+ this.#unanchoredBytes = 0;
1054
+ this.#unanchoredMutations = 0;
1055
+ this.#unflushedBytes = 0;
1056
+ this.#unflushedMutations = 0;
1057
+ this.#pendingBatch = undefined;
1058
+ this.#carried = [];
1059
+ this.#latestSeen.clear();
1060
+ this.#pendingTimestamps.clear();
1061
+ this.#seenTimestamps.clear();
1062
+ for (const [logName, timestamp] of Object.entries(cursor.logs)) {
1063
+ this.#pendingTimestamps.set(logName, [timestamp]);
1064
+ this.#seenTimestamps.set(logName, new Set([timestamp]));
1065
+ }
1066
+ this.#iterable = this.#logStore.getRange({
1067
+ startByLog: new Map(Object.entries(cursor.logs)),
1068
+ exactStart: true,
1069
+ exclusiveStart: true,
1070
+ resumeAfterExactStart: true,
1071
+ includeLogName: true,
1072
+ });
1073
+ this.#iterator = this.#iterable[Symbol.iterator]();
1074
+ return this.#checkRangeHealth();
1075
+ }
1076
+
1077
+ #validateLogSet(cursor: DerivedIndexCursor) {
1078
+ const currentLogs = this.#logStore.rootStore.listLogs();
1079
+ const current = new Set(currentLogs);
1080
+ for (const logName of Object.keys(cursor.logs)) {
1081
+ if (!current.has(logName)) {
1082
+ this.#needsRebuild(`saved transaction log '${logName}' is missing`, 'log-missing');
1083
+ return;
1084
+ }
1085
+ }
1086
+ this.#knownLogs = current;
1087
+ for (const logName of currentLogs) {
1088
+ if (cursor.logs[logName] !== undefined) continue;
1089
+ if (!this.#retainsBeginning(logName)) {
1090
+ this.#needsRebuild(`new transaction log '${logName}' no longer retains its beginning`, 'log-retention');
1091
+ return;
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ /** A log that never wrote a file has nothing to have lost; one with files must still hold the first. */
1097
+ #retainsBeginning(logName: string): boolean {
1098
+ const stats = this.#logStore.rootStore.useLog(logName).getStats();
1099
+ return stats.fileCount === 0 || stats.oldestSequenceNumber === 1;
1100
+ }
1101
+
1102
+ #drain() {
1103
+ if (!this.#owned || this.#stopped || this.#rebuilding) return;
1104
+ if (this.#canRebuild() && this.#takeSharedRebuildRequest()) {
1105
+ if (this.#rebuildTimer) {
1106
+ clearTimeout(this.#rebuildTimer);
1107
+ this.#rebuildTimer = undefined;
1108
+ }
1109
+ this.#rebuildAttempts = 0;
1110
+ this.#startRebuild();
1111
+ return;
1112
+ }
1113
+ if (this.status.state === 'needs-rebuild' || this.status.state === 'unavailable') return;
1114
+ const generation = this.#generation;
1115
+ const now = this.#options.now();
1116
+ this.#publishLag();
1117
+ try {
1118
+ const captureTime = this.#registration.backend.publishCoverage ? derivedIndexTime(this.#logStore.rootStore) : 0n;
1119
+ if (!this.#checkNewLogs() || !this.#checkRangeHealth()) return;
1120
+ const positions = this.#registration.backend.publishCoverage
1121
+ ? readCommittedPositions(this.#logStore, this.#knownLogs)
1122
+ : undefined;
1123
+ const capture = positions ? { time: captureTime, positions } : undefined;
1124
+ if (this.status.state === 'waiting-durable') {
1125
+ if (!this.#reconcileDurableCursor()) return;
1126
+ if (this.#offeredCursors.length - 1 >= this.#options.maxAcceptedBatchesAhead) return;
1127
+ this.status = { state: 'running', ownerEpoch: this.#ownerEpoch };
1128
+ }
1129
+ this.#collectedToEnd = false;
1130
+ const batch = this.#pendingBatch ?? this.#collectChunk();
1131
+ if (!this.#live(generation)) return;
1132
+ if (batch === CONTINUE) {
1133
+ this.status = { state: 'running', ownerEpoch: this.#ownerEpoch };
1134
+ this.wake();
1135
+ return;
1136
+ }
1137
+ if (!batch) {
1138
+ this.#finishIdlePass(capture);
1139
+ return;
1140
+ }
1141
+ const result = this.#deliver(batch);
1142
+ if (result === undefined) return;
1143
+ if (result === DERIVED_INDEX_DEFERRED) {
1144
+ this.#pendingBatch = batch;
1145
+ this.status = { state: 'deferred', ownerEpoch: this.#ownerEpoch };
1146
+ this.#stalledSince ??= now;
1147
+ return;
1148
+ }
1149
+ this.#pendingBatch = undefined;
1150
+ this.#noteAccepted(batch);
1151
+ if (!this.#live(generation)) return;
1152
+ // A nonempty turn can also exhaust the captured log tail. Waiting for an empty turn
1153
+ // would starve coverage under continuous writes even when each drain catches up.
1154
+ const completedCapture = this.#collectedToEnd && this.#carried.length === 0 ? capture : undefined;
1155
+ if (completedCapture) this.#offeredCursors.at(-1)!.coverage = completedCapture;
1156
+ if (!this.#reconcileDurableCursor()) return;
1157
+ this.#publishUnchangedCoverage(completedCapture);
1158
+ this.#publishLag();
1159
+ if (!lastOpen(this.#carried) && this.#offeredCursors.length - 1 >= this.#options.maxAcceptedBatchesAhead) {
1160
+ this.status = { state: 'waiting-durable', ownerEpoch: this.#ownerEpoch };
1161
+ this.#stalledSince ??= now;
1162
+ return;
1163
+ }
1164
+ this.#stalledSince = undefined;
1165
+ this.status = { state: 'running', ownerEpoch: this.#ownerEpoch };
1166
+ this.wake();
1167
+ } catch (error) {
1168
+ this.#fail('runner drain failed', error);
1169
+ }
1170
+ }
1171
+
1172
+ #deliver(batch: DerivedIndexBatch): typeof DERIVED_INDEX_ACCEPTED | typeof DERIVED_INDEX_DEFERRED | undefined {
1173
+ const generation = this.#generation;
1174
+ let result: DerivedIndexDeliveryResult;
1175
+ try {
1176
+ result = this.#registration.backend.deliver(batch);
1177
+ } catch (error) {
1178
+ this.#fail('backend delivery threw', error, 'backend-failed');
1179
+ return;
1180
+ }
1181
+ if (!this.#live(generation)) return;
1182
+ if (result === DERIVED_INDEX_DEFERRED || result === DERIVED_INDEX_ACCEPTED) return result;
1183
+ this.#needsRebuild(
1184
+ result === DERIVED_INDEX_FAILED ? 'backend rejected a delivery batch' : 'backend returned an invalid result',
1185
+ 'backend-failed'
1186
+ );
1187
+ }
1188
+
1189
+ #noteAccepted(batch: DerivedIndexBatch) {
1190
+ const now = this.#options.now();
1191
+ if (batch.through && !sameCursor(batch.through, this.#offered)) {
1192
+ this.#offered = cloneCursor(batch.through);
1193
+ this.#offeredCursors.push({
1194
+ cursor: cloneCursor(batch.through),
1195
+ bytes: this.#unanchoredBytes + batch.bytes,
1196
+ mutations: this.#unanchoredMutations + batch.records.length,
1197
+ acceptedAt: this.#unanchoredMutations > 0 ? this.#unanchoredAcceptedAt : now,
1198
+ });
1199
+ this.#unanchoredBytes = 0;
1200
+ this.#unanchoredMutations = 0;
1201
+ } else {
1202
+ if (this.#unanchoredMutations === 0) this.#unanchoredAcceptedAt = now;
1203
+ this.#unanchoredBytes += batch.bytes;
1204
+ this.#unanchoredMutations += batch.records.length;
1205
+ }
1206
+ this.#unflushedBytes += batch.bytes;
1207
+ this.#unflushedMutations += batch.records.length;
1208
+ if (
1209
+ this.#unflushedMutations >= this.#options.flushAfterMutations ||
1210
+ this.#unflushedBytes >= this.#options.flushAfterBytes
1211
+ ) {
1212
+ this.#requestFlush('threshold');
1213
+ } else this.#armFlushTimer();
1214
+ }
1215
+
1216
+ #requestFlush(reason: DerivedIndexFlushReason) {
1217
+ if (this.#flushTimer) {
1218
+ clearTimeout(this.#flushTimer);
1219
+ this.#flushTimer = undefined;
1220
+ }
1221
+ this.#unflushedBytes = 0;
1222
+ this.#unflushedMutations = 0;
1223
+ const generation = this.#generation;
1224
+ try {
1225
+ const result = this.#registration.backend.flush(reason);
1226
+ if (result && typeof result.then === 'function') {
1227
+ result.then(undefined, (error: unknown) => {
1228
+ if (this.#live(generation)) this.#fail('backend flush request rejected', error, 'backend-failed');
1229
+ });
1230
+ }
1231
+ } catch (error) {
1232
+ this.#fail('backend flush request threw', error, 'backend-failed');
1233
+ return;
1234
+ }
1235
+ // A backend may coalesce this into a barrier already running; keep asking while work is not durable.
1236
+ if (this.#hasNonDurableWork()) this.#armFlushTimer();
1237
+ }
1238
+
1239
+ #hasNonDurableWork(): boolean {
1240
+ return this.#offeredCursors.length > 1 || this.#unanchoredMutations > 0 || this.#boundaryPending;
1241
+ }
1242
+
1243
+ #armFlushTimer() {
1244
+ if (this.#flushTimer) return;
1245
+ this.#flushTimer = setTimeout(() => {
1246
+ this.#flushTimer = undefined;
1247
+ if (!this.#owned) return;
1248
+ this.#publishLag();
1249
+ this.#requestFlush('age');
1250
+ }, this.#options.maxFlushAgeMilliseconds);
1251
+ this.#flushTimer.unref?.();
1252
+ }
1253
+
1254
+ #collectChunk(): DerivedIndexBatch | typeof CONTINUE | undefined {
1255
+ const chunk = this.#newChunk(false);
1256
+ const collected = this.#collectIdentities(chunk.started);
1257
+ if (!this.#checkRangeHealth()) return;
1258
+ if (collected.length === 0) return;
1259
+ return this.#resolveCollected(chunk, collected);
1260
+ }
1261
+
1262
+ #collectIdentities(started: number): CollectedTransaction[] {
1263
+ const options = this.#options;
1264
+ const iterator = this.#iterator!;
1265
+ const projections = this.#registration.projections;
1266
+ const collected = this.#carried;
1267
+ this.#carried = [];
1268
+ let keyCount = 0;
1269
+ for (const transaction of collected) keyCount += transaction.keyCount;
1270
+ let current = lastOpen(collected);
1271
+ let transactions = 0;
1272
+ let readBytes = 0;
1273
+ let entries = 0;
1274
+ while (keyCount < options.maxChunkRecords) {
1275
+ const next = iterator.next();
1276
+ if (next.done) {
1277
+ if (current)
1278
+ throw new RunnerError(
1279
+ 'log-corrupt',
1280
+ `transaction ${current.timestamp} from '${current.logName}' is incomplete`
1281
+ );
1282
+ this.#collectedToEnd = true;
1283
+ break;
1284
+ }
1285
+ const entry = next.value;
1286
+ this.#assertRecord(entry);
1287
+ if (!current) {
1288
+ const logName = entry.logName!;
1289
+ const timestamp = entry.txnLogKey;
1290
+ let seen = this.#seenTimestamps.get(logName);
1291
+ if (!seen) this.#seenTimestamps.set(logName, (seen = new Set()));
1292
+ if (seen.has(timestamp))
1293
+ throw new RunnerError(
1294
+ 'log-corrupt',
1295
+ `transaction log '${logName}' repeated completed timestamp ${timestamp}`
1296
+ );
1297
+ current = { logName, timestamp, keys: new Map(), keyCount: 0, complete: false };
1298
+ collected.push(current);
1299
+ } else if (entry.logName !== current.logName || entry.txnLogKey !== current.timestamp) {
1300
+ throw new RunnerError(
1301
+ 'log-corrupt',
1302
+ `transaction ${current.timestamp} from '${current.logName}' ended without an endTxn boundary`
1303
+ );
1304
+ }
1305
+ readBytes += entry.size ?? 0;
1306
+ entries++;
1307
+ const projection = projections.get(entry.tableId);
1308
+ if (projection) {
1309
+ if (entry.type === 'reload') {
1310
+ // A rebuild anchors its replay at the committed tail captured before its scan, so a marker
1311
+ // is met exactly once: here, before the rebuild it demands.
1312
+ throw new RunnerError('reload', `table ${entry.tableId} requires a derived-index rebuild`);
1313
+ } else if (ELIGIBLE_ACTIONS.has(entry.type)) {
1314
+ let byRecord = current.keys.get(entry.tableId);
1315
+ if (!byRecord) current.keys.set(entry.tableId, (byRecord = new Map()));
1316
+ const key = writeKeyId(entry.recordId);
1317
+ const known = byRecord.get(key);
1318
+ if (known) known.logVersion = entry.version;
1319
+ else {
1320
+ byRecord.set(key, { recordId: entry.recordId, logVersion: entry.version, sizeHint: entry.size });
1321
+ current.keyCount++;
1322
+ keyCount++;
1323
+ }
1324
+ }
1325
+ }
1326
+ if (entry.endTxn) {
1327
+ current.complete = true;
1328
+ this.#latestSeen.set(current.logName, current.timestamp);
1329
+ this.#seenTimestamps.get(current.logName)!.add(current.timestamp);
1330
+ let pendingTimestamps = this.#pendingTimestamps.get(current.logName);
1331
+ if (!pendingTimestamps) this.#pendingTimestamps.set(current.logName, (pendingTimestamps = []));
1332
+ pendingTimestamps.push(current.timestamp);
1333
+ current = undefined;
1334
+ transactions++;
1335
+ if (
1336
+ transactions >= options.maxTransactionsPerTurn ||
1337
+ readBytes >= options.maxBytesPerTurn ||
1338
+ options.now() - started >= options.maxMillisecondsPerTurn
1339
+ )
1340
+ break;
1341
+ } else if ((entries & 15) === 0 && options.now() - started >= options.maxMillisecondsPerTurn) break;
1342
+ }
1343
+ return collected;
1344
+ }
1345
+
1346
+ #resolveCollected(chunk: Chunk, collected: CollectedTransaction[]): DerivedIndexBatch | typeof CONTINUE {
1347
+ const options = this.#options;
1348
+ const through = cloneCursor(this.#offered!);
1349
+ let completed = 0;
1350
+ let visited = 0;
1351
+ for (let i = 0; i < collected.length; i++) {
1352
+ const transaction = collected[i];
1353
+ const mutations: DerivedIndexMutation[] = [];
1354
+ let remaining: CollectedTransaction | undefined;
1355
+ for (const [tableId, byRecord] of transaction.keys) {
1356
+ for (const [key, collectedKey] of byRecord) {
1357
+ if (
1358
+ !remaining &&
1359
+ chunk.batch.records.length > 0 &&
1360
+ (chunk.batch.bytes >= options.maxChunkBytes ||
1361
+ ((++visited & 15) === 0 && options.now() - chunk.started >= options.maxMillisecondsPerTurn))
1362
+ ) {
1363
+ remaining = { ...transaction, keys: new Map(), keyCount: 0 };
1364
+ }
1365
+ if (remaining) {
1366
+ let rest = remaining.keys.get(tableId);
1367
+ if (!rest) remaining.keys.set(tableId, (rest = new Map()));
1368
+ rest.set(key, collectedKey);
1369
+ remaining.keyCount++;
1370
+ continue;
1371
+ }
1372
+ const record = this.#addMutation(chunk, tableId, key, collectedKey);
1373
+ mutations.push({
1374
+ tableId,
1375
+ recordId: collectedKey.recordId,
1376
+ logVersion: collectedKey.logVersion,
1377
+ state: record.state,
1378
+ });
1379
+ }
1380
+ }
1381
+ if (remaining) {
1382
+ if (mutations.length)
1383
+ chunk.batch.transactions.push({ logName: transaction.logName, timestamp: transaction.timestamp, mutations });
1384
+ this.#carried = [remaining, ...collected.slice(i + 1)];
1385
+ break;
1386
+ }
1387
+ if (transaction.complete) {
1388
+ through.logs[transaction.logName] = transaction.timestamp;
1389
+ completed++;
1390
+ if (mutations.length)
1391
+ chunk.batch.transactions.push({ logName: transaction.logName, timestamp: transaction.timestamp, mutations });
1392
+ } else {
1393
+ if (mutations.length)
1394
+ chunk.batch.transactions.push({ logName: transaction.logName, timestamp: transaction.timestamp, mutations });
1395
+ this.#carried = [
1396
+ {
1397
+ logName: transaction.logName,
1398
+ timestamp: transaction.timestamp,
1399
+ keys: new Map(),
1400
+ keyCount: 0,
1401
+ complete: false,
1402
+ },
1403
+ ];
1404
+ }
1405
+ }
1406
+ this.#noteChunkProjection(chunk);
1407
+ if (completed === 0 && chunk.batch.records.length === 0) return CONTINUE;
1408
+ chunk.batch.through = through;
1409
+ return chunk.batch;
1410
+ }
1411
+
1412
+ #newChunk(rebuild: boolean): Chunk {
1413
+ const batch = { ownerEpoch: this.#ownerEpoch!, transactions: [] } as unknown as DerivedIndexBatch;
1414
+ Object.defineProperties(batch, {
1415
+ records: { value: [], writable: true, configurable: true },
1416
+ bytes: { value: 0, writable: true, configurable: true },
1417
+ });
1418
+ if (rebuild) batch.rebuild = true;
1419
+ return { batch, resolved: new Map(), started: this.#options.now() };
1420
+ }
1421
+
1422
+ #addMutation(chunk: Chunk, tableId: number, key: unknown, collectedKey: CollectedKey): DerivedIndexMutation {
1423
+ let byRecord = chunk.resolved.get(tableId);
1424
+ if (!byRecord) chunk.resolved.set(tableId, (byRecord = new Map()));
1425
+ let record = byRecord.get(key);
1426
+ if (record) {
1427
+ record.logVersion = collectedKey.logVersion;
1428
+ return record;
1429
+ }
1430
+ const current = this.#resolveRecord(tableId, collectedKey.recordId);
1431
+ const state: DerivedIndexState = current
1432
+ ? this.#project(chunk, tableId, current.value, current.version, current.size ?? collectedKey.sizeHint)
1433
+ : { kind: 'absent' };
1434
+ record = { tableId, recordId: collectedKey.recordId, logVersion: collectedKey.logVersion, state };
1435
+ byRecord.set(key, record);
1436
+ chunk.batch.records.push(record);
1437
+ return record;
1438
+ }
1439
+
1440
+ /** A chunk the projection rejected outright is worth one warning per streak, never an outage. */
1441
+ #noteChunkProjection(chunk: Chunk) {
1442
+ const records = chunk.batch.records;
1443
+ if (records.length === 0) return;
1444
+ if (records.some((record) => record.state.kind !== 'unindexable')) {
1445
+ this.#allUnindexableWarned = false;
1446
+ return;
1447
+ }
1448
+ if (this.#allUnindexableWarned) return;
1449
+ this.#allUnindexableWarned = true;
1450
+ logger.warn?.(
1451
+ `Derived index '${this.#registration.backend.id}' could not project any of the ${records.length} records in a chunk; they are counted in unindexableRecords`
1452
+ );
1453
+ }
1454
+
1455
+ #project(
1456
+ chunk: Chunk,
1457
+ tableId: number,
1458
+ value: unknown,
1459
+ version: number,
1460
+ size: number | undefined
1461
+ ): DerivedIndexState {
1462
+ chunk.batch.bytes += size ?? 0;
1463
+ try {
1464
+ return { kind: 'record', version, projection: this.#registration.projections.get(tableId)!(value) };
1465
+ } catch (error) {
1466
+ const statusCode = (error as { statusCode?: unknown })?.statusCode;
1467
+ if (typeof statusCode !== 'number' || statusCode < 400 || statusCode >= 500) throw error;
1468
+ // Validation messages can quote record values, which must not reach the backend or the log.
1469
+ const reason = `${error instanceof Error && error.name ? error.name : 'Error'} (${statusCode})`;
1470
+ if (this.#unindexableRecords++ === 0)
1471
+ logger.warn?.(`Derived index '${this.#registration.backend.id}' skipped a record it cannot project: ${reason}`);
1472
+ return { kind: 'unindexable', version, reason };
1473
+ }
1474
+ }
1475
+
1476
+ #assertRecord(record: AuditRecord) {
1477
+ if (!record || record.logName === undefined || record.tableId === undefined || record.type === undefined)
1478
+ throw new RunnerError('log-corrupt', 'transaction log yielded an undecodable audit entry');
1479
+ }
1480
+
1481
+ #checkNewLogs(): boolean {
1482
+ const current = this.#logStore.rootStore.listLogs();
1483
+ const currentSet = new Set(current);
1484
+ for (const logName of this.#knownLogs) {
1485
+ if (!currentSet.has(logName)) {
1486
+ this.#needsRebuild(`transaction log '${logName}' was removed`, 'log-missing');
1487
+ return false;
1488
+ }
1489
+ }
1490
+ for (const logName of current) {
1491
+ if (this.#registration.backend.publishCoverage) this.#logStore.ensureLogExists(logName);
1492
+ if (this.#knownLogs.has(logName)) continue;
1493
+ if (!this.#retainsBeginning(logName)) {
1494
+ this.#needsRebuild(`new transaction log '${logName}' no longer retains its beginning`, 'log-retention');
1495
+ return false;
1496
+ }
1497
+ this.#knownLogs.add(logName);
1498
+ }
1499
+ return true;
1500
+ }
1501
+
1502
+ #checkRangeHealth(iterable = this.#iterable): boolean {
1503
+ if (!iterable) return true;
1504
+ if (iterable.corruptFrameStop.breaks > 0) {
1505
+ this.#needsRebuild('transaction log contains a corrupt frame', 'log-corrupt');
1506
+ return false;
1507
+ }
1508
+ if (iterable.failedLogs.size > 0) {
1509
+ this.#needsRebuild(
1510
+ `transaction log iterator failed for '${iterable.failedLogs.values().next().value}'`,
1511
+ 'log-corrupt'
1512
+ );
1513
+ return false;
1514
+ }
1515
+ if (iterable.exactStartFailures.size > 0) {
1516
+ const [logName, failure] = iterable.exactStartFailures.entries().next().value;
1517
+ this.#needsRebuild(`transaction log '${logName}' has a ${failure} durable cursor boundary`, 'log-retention');
1518
+ return false;
1519
+ }
1520
+ return true;
1521
+ }
1522
+
1523
+ #finishIdlePass(capture?: CoverageCapture) {
1524
+ this.#stalledSince = undefined;
1525
+ this.#unreadSince = undefined;
1526
+ this.#reachedEndOfLog = true;
1527
+ if (this.#rebuilding || !this.#offered) return;
1528
+ const durable = this.#registration.backend.getDurableCursor();
1529
+ if (durable === undefined && this.#boundaryPending) {
1530
+ this.#armFlushTimer();
1531
+ return;
1532
+ }
1533
+ if (!isValidCursor(durable)) {
1534
+ this.#needsRebuild('backend lost its durable cursor', 'cursor-missing');
1535
+ return;
1536
+ }
1537
+ if (capture) this.#offeredCursors.at(-1)!.coverage = capture;
1538
+ if (!this.#reconcileDurableCursor(durable)) return;
1539
+ this.#publishUnchangedCoverage(capture);
1540
+ this.#armCoverageTimer();
1541
+ if (sameCursor(durable, this.#offered!)) this.#lastCaughtUpAt = this.#options.now();
1542
+ this.#publishLag();
1543
+ if (!sameCursor(durable, this.#offered!)) {
1544
+ this.#armFlushTimer();
1545
+ return;
1546
+ }
1547
+ this.#settleReady();
1548
+ if (this.#idleTimer) return;
1549
+ this.status = { state: 'idle', ownerEpoch: this.#ownerEpoch };
1550
+ this.#idleTimer = setTimeout(() => {
1551
+ this.#idleTimer = undefined;
1552
+ if (this.#stopped) return;
1553
+ try {
1554
+ if (sameCursor(this.#registration.backend.getDurableCursor(), this.#offered!)) this.#release();
1555
+ } catch (error) {
1556
+ this.#fail('backend cursor read threw at idle release', error, 'backend-failed');
1557
+ }
1558
+ }, this.#options.idleGraceMilliseconds);
1559
+ }
1560
+
1561
+ #publishUnchangedCoverage(capture?: CoverageCapture) {
1562
+ if (
1563
+ capture &&
1564
+ !this.#boundaryPending &&
1565
+ this.#unanchoredMutations === 0 &&
1566
+ this.#offeredCursors.slice(1).every((offered) => offered.mutations === 0)
1567
+ )
1568
+ this.#publishCoverage(capture);
1569
+ }
1570
+
1571
+ #reconcileDurableCursor(cursor = this.#registration.backend.getDurableCursor()): boolean {
1572
+ if (cursor === undefined && this.#boundaryPending) return true;
1573
+ if (!isValidCursor(cursor)) {
1574
+ this.#needsRebuild('backend returned an invalid durable cursor', 'cursor-missing');
1575
+ return false;
1576
+ }
1577
+ const offeredIndex = this.#offeredCursors.findIndex((offered) => sameCursor(cursor, offered.cursor));
1578
+ if (offeredIndex < 0) {
1579
+ this.#needsRebuild('backend advanced to an unoffered cursor vector', 'cursor-unoffered');
1580
+ return false;
1581
+ }
1582
+ // Every timestamp of an offered vector was pushed to its log's pending list, so the durable one is
1583
+ // present; everything before it is no longer needed for repeat detection.
1584
+ for (const [logName, timestamp] of Object.entries(cursor.logs)) {
1585
+ const pending = this.#pendingTimestamps.get(logName)!;
1586
+ const index = pending.indexOf(timestamp);
1587
+ if (index > 0) {
1588
+ const retained = pending.slice(index);
1589
+ this.#pendingTimestamps.set(logName, retained);
1590
+ this.#seenTimestamps.set(logName, new Set(retained));
1591
+ }
1592
+ }
1593
+ this.#boundaryPending = false;
1594
+ for (let i = offeredIndex; i >= 0; i--) {
1595
+ const coverage = this.#offeredCursors[i].coverage;
1596
+ if (coverage) {
1597
+ this.#publishCoverage(coverage);
1598
+ break;
1599
+ }
1600
+ }
1601
+ if (offeredIndex > 0) {
1602
+ this.#offeredCursors.splice(0, offeredIndex);
1603
+ if (!this.#rebuilding && this.status.state !== 'needs-rebuild') this.#settleReady();
1604
+ }
1605
+ if (offeredIndex > 0 || sameCursor(cursor, this.#offered)) this.#lastCaughtUpAt = this.#options.now();
1606
+ return true;
1607
+ }
1608
+
1609
+ #publishCoverage(capture: CoverageCapture) {
1610
+ if (!this.#owned || this.#rebuilding || Atomics.load(this.#shared().epoch, 0) !== this.#ownerEpoch) return;
1611
+ if (capture.time <= Atomics.load(this.#shared().coverage, 0)) return;
1612
+ if (Number(capture.time - this.#lastPersistedCoverageTime) / 1e6 >= this.#options.maxFlushAgeMilliseconds) {
1613
+ try {
1614
+ this.#registration.backend.publishCoverage!(capture.positions, this.#ownerEpoch!);
1615
+ this.#lastPersistedCoverageTime = capture.time;
1616
+ } catch (error) {
1617
+ logger.warn?.(`Derived index '${this.id}' could not persist query coverage`, error);
1618
+ return;
1619
+ }
1620
+ }
1621
+ Atomics.store(this.#shared().coverage, 0, capture.time);
1622
+ }
1623
+
1624
+ #armCoverageTimer() {
1625
+ if (!this.#registration.backend.publishCoverage || this.#coverageTimer || !this.#owned) return;
1626
+ this.#coverageTimer = setTimeout(() => {
1627
+ this.#coverageTimer = undefined;
1628
+ this.#drain();
1629
+ }, this.#options.maxFlushAgeMilliseconds);
1630
+ this.#coverageTimer.unref?.();
1631
+ }
1632
+
1633
+ #settleReady() {
1634
+ this.#rebuildAttempts = 0;
1635
+ if (this.#condemned) this.#clearCondemnation();
1636
+ if (Atomics.load(this.#shared().words, READINESS_STATE) !== READINESS_STATES.indexOf('ready'))
1637
+ this.#publishReadiness('ready');
1638
+ }
1639
+
1640
+ /**
1641
+ * Shared readiness is process memory, so a condemnation is also written to the root store under
1642
+ * the index's marker key; a restart before the rebuild's `reset` has durably invalidated the
1643
+ * cursor then still rebuilds instead of trusting it. Durability follows the root store's WAL
1644
+ * setting. The marker clears only at the first durable `ready` after the rebuild, so a crash
1645
+ * before that costs one extra rebuild, never a trusted condemned cursor. A log store without a
1646
+ * root-store key-value surface (test fakes) keeps Stage 1's process-memory condemnation only.
1647
+ */
1648
+ #writeCondemnation(): boolean {
1649
+ if (this.#condemned || !this.#markersSupported) return true;
1650
+ try {
1651
+ this.#logStore.putSync(this.#markerKey, CONDEMNED_MARKER, {});
1652
+ this.#condemned = true;
1653
+ return true;
1654
+ } catch (error) {
1655
+ // Without the marker a crash mid-reset would reopen on the condemned cursor, so no reset runs.
1656
+ logger.error(`Derived index '${this.id}' could not persist its condemnation`, error);
1657
+ return false;
1658
+ }
1659
+ }
1660
+
1661
+ #readCondemnation(): boolean {
1662
+ if (!this.#markersSupported) return false;
1663
+ try {
1664
+ this.#condemned = this.#logStore.rootStore.getSync(this.#markerKey) !== undefined;
1665
+ return this.#condemned;
1666
+ } catch (error) {
1667
+ logger.error(`Derived index '${this.id}' could not read its condemnation marker`, error);
1668
+ return true;
1669
+ }
1670
+ }
1671
+
1672
+ /** No rebuild attempt is spent on a refused marker; the next acquirer retries it before any reset. */
1673
+ #deferForCondemnation(code: DerivedIndexReadinessReason) {
1674
+ logger.error(`Derived index '${this.id}' condemnation could not be persisted; retrying at the next wake`);
1675
+ const reason = this.status.state === 'needs-rebuild' ? this.status.reason : 'condemnation not persisted';
1676
+ this.status = { state: 'needs-rebuild', reason, ownerEpoch: this.#ownerEpoch };
1677
+ this.#publishReadiness('needs-rebuild', code);
1678
+ this.#rebuildRequested = true;
1679
+ // The retry needs a wake, and wakes come from commits: shedding them would be the only thing
1680
+ // keeping this park from ever ending.
1681
+ this.#admitWrites();
1682
+ this.#release();
1683
+ }
1684
+
1685
+ #clearCondemnation() {
1686
+ if (!this.#markersSupported) {
1687
+ this.#condemned = false;
1688
+ return;
1689
+ }
1690
+ try {
1691
+ this.#logStore.rootStore.removeSync(this.#markerKey);
1692
+ this.#condemned = false;
1693
+ } catch (error) {
1694
+ logger.error(`Derived index '${this.id}' could not clear its condemnation marker`, error);
1695
+ }
1696
+ }
1697
+
1698
+ #backendStateChanged(change: DerivedIndexBackendStateChange) {
1699
+ if (this.#stopped || this.status.state === 'unavailable') return;
1700
+ if (change === 'failed') {
1701
+ this.#needsRebuild('backend reported a permanent failure', 'backend-failed');
1702
+ return;
1703
+ }
1704
+ if (this.#rebuilding) {
1705
+ if (change === 'accepted-work-lost') {
1706
+ this.#rebuildFailed('backend lost accepted rebuild work', 'backend-failed');
1707
+ return;
1708
+ }
1709
+ if (this.#rebuildWaiter) this.#rebuildWaiter();
1710
+ else this.#rebuildWakePending = true;
1711
+ return;
1712
+ }
1713
+ if (this.status.state === 'needs-rebuild') return;
1714
+ if (change === 'accepted-work-lost' && this.#owned) {
1715
+ this.#discardProgress();
1716
+ try {
1717
+ this.#resetFromDurableCursor();
1718
+ } catch (error) {
1719
+ this.#fail('failed to reset lost accepted work', error, 'backend-failed');
1720
+ return;
1721
+ }
1722
+ }
1723
+ this.wake(true);
1724
+ }
1725
+
1726
+ /**
1727
+ * The error's message stays in the local status and log, since backend messages can quote record
1728
+ * content; only the code reaches shared memory. A collector error carries its own code.
1729
+ */
1730
+ #fail(reason: string, error: unknown, code: DerivedIndexReadinessReason = 'runner-failed') {
1731
+ const detail = error instanceof Error && error.message ? `${reason}: ${error.message}` : reason;
1732
+ this.#needsRebuild(detail, error instanceof RunnerError ? error.code : code, error);
1733
+ }
1734
+
1735
+ #needsRebuild(reason: string, code: DerivedIndexReadinessReason, error?: unknown) {
1736
+ if (this.#rebuilding) {
1737
+ this.#rebuildFailed(reason, code, error);
1738
+ return;
1739
+ }
1740
+ if (this.status.state !== 'needs-rebuild')
1741
+ logger.error(`Derived index '${this.#registration.backend.id}' needs rebuild: ${reason}`, error);
1742
+ this.status = { state: 'needs-rebuild', reason, ownerEpoch: this.#ownerEpoch };
1743
+ this.#discardProgress();
1744
+ if (!this.#owned) return;
1745
+ if (!this.#writeCondemnation()) {
1746
+ this.#deferForCondemnation(code);
1747
+ return;
1748
+ }
1749
+ if (this.#canRebuild()) {
1750
+ // A failure after a rebuild but before `ready` is that rebuild failing late; it counts against the cap.
1751
+ if (this.#rebuildAttempts >= this.#options.maxRebuildAttempts) {
1752
+ this.#becomeUnavailable(reason, code, error);
1753
+ return;
1754
+ }
1755
+ this.#publishReadiness('needs-rebuild', code);
1756
+ this.#rebuildRequested = true;
1757
+ this.#scheduleRebuild();
1758
+ return;
1759
+ }
1760
+ this.#publishReadiness('needs-rebuild', code);
1761
+ this.#admitWrites();
1762
+ this.#release();
1763
+ }
1764
+
1765
+ #becomeUnavailable(reason: string, code: DerivedIndexReadinessReason, error?: unknown) {
1766
+ logger.error(
1767
+ `Derived index '${this.#registration.backend.id}' is unavailable after ${this.#rebuildAttempts} rebuild attempts: ${reason}`,
1768
+ error
1769
+ );
1770
+ this.status = { state: 'unavailable', reason, ownerEpoch: this.#ownerEpoch };
1771
+ this.#publishReadiness('unavailable', code);
1772
+ this.#admitWrites();
1773
+ this.#release();
1774
+ }
1775
+
1776
+ /** An index no owner will catch up must not keep shedding writes. */
1777
+ #admitWrites() {
1778
+ if (this.#lagBudget > 0) Atomics.store(this.#shared().words, READINESS_LAG_EXCEEDED, 0);
1779
+ }
1780
+
1781
+ #discardProgress() {
1782
+ if (this.#coverageTimer) clearTimeout(this.#coverageTimer);
1783
+ this.#coverageTimer = undefined;
1784
+ this.#lastPersistedCoverageTime = 0n;
1785
+ for (const offered of this.#offeredCursors) delete offered.coverage;
1786
+ this.#generation++;
1787
+ this.#stalledSince = undefined;
1788
+ this.#lastCaughtUpAt = undefined;
1789
+ this.#unreadSince = this.#options.now();
1790
+ this.#reachedEndOfLog = false;
1791
+ this.#offered = undefined;
1792
+ this.#pendingBatch = undefined;
1793
+ this.#carried = [];
1794
+ try {
1795
+ this.#iterator?.return?.();
1796
+ } catch (error) {
1797
+ logger.warn?.(`Derived index '${this.#registration.backend.id}' log iterator close threw`, error);
1798
+ }
1799
+ this.#iterator = undefined;
1800
+ this.#iterable = undefined;
1801
+ this.#boundaryPending = false;
1802
+ if (this.#flushTimer) {
1803
+ clearTimeout(this.#flushTimer);
1804
+ this.#flushTimer = undefined;
1805
+ }
1806
+ }
1807
+
1808
+ #scheduleRebuild() {
1809
+ if (this.#rebuildTimer || this.#stopped) return;
1810
+ const attempt = this.#rebuildAttempts;
1811
+ if (attempt === 0) {
1812
+ this.#startRebuild();
1813
+ return;
1814
+ }
1815
+ const delay = Math.min(
1816
+ this.#options.rebuildBackoffMilliseconds * 2 ** (attempt - 1),
1817
+ this.#options.maxRebuildBackoffMilliseconds
1818
+ );
1819
+ this.#rebuildTimer = setTimeout(() => {
1820
+ this.#rebuildTimer = undefined;
1821
+ if (this.#stopped) return;
1822
+ if (this.#owned) this.#startRebuild();
1823
+ else this.wake(true);
1824
+ }, delay);
1825
+ this.#rebuildTimer.unref?.();
1826
+ }
1827
+
1828
+ #startRebuild() {
1829
+ if (!this.#owned || this.#rebuilding || this.#stopped) return;
1830
+ this.#rebuildRequested = false;
1831
+ this.#takeSharedRebuildRequest();
1832
+ this.#rebuilding = true;
1833
+ if (this.#lagTimer) {
1834
+ clearTimeout(this.#lagTimer);
1835
+ this.#lagTimer = undefined;
1836
+ }
1837
+ this.#rebuildWakePending = false;
1838
+ if (this.#idleTimer) {
1839
+ clearTimeout(this.#idleTimer);
1840
+ this.#idleTimer = undefined;
1841
+ }
1842
+ this.#discardProgress();
1843
+ // The lag policy guards a durable cursor against retention; a rebuild has none to guard, and its
1844
+ // replay anchor is captured fresh after the scan starts. Readers act on `rebuilding` instead.
1845
+ this.#admitWrites();
1846
+ if (!this.#writeCondemnation()) {
1847
+ this.#rebuilding = false;
1848
+ this.#deferForCondemnation('rebuild-requested');
1849
+ return;
1850
+ }
1851
+ if (this.#rebuildAttempts >= this.#options.maxRebuildAttempts) {
1852
+ this.#rebuilding = false;
1853
+ this.#becomeUnavailable('rebuild budget exhausted by a previous owner', 'rebuild-exhausted');
1854
+ return;
1855
+ }
1856
+ const generation = this.#generation;
1857
+ this.status = { state: 'rebuilding', ownerEpoch: this.#ownerEpoch };
1858
+ this.#rebuildAttempts++;
1859
+ this.#publishReadiness('rebuilding');
1860
+ this.#runRebuild(generation).then(
1861
+ () => {
1862
+ if (!this.#live(generation)) return;
1863
+ this.#rebuilding = false;
1864
+ this.#rebuildRequested = false;
1865
+ this.#takeSharedRebuildRequest();
1866
+ this.status = { state: 'running', ownerEpoch: this.#ownerEpoch };
1867
+ this.#drain();
1868
+ },
1869
+ (error) => {
1870
+ if (!this.#live(generation)) return;
1871
+ this.#rebuildFailed(
1872
+ error instanceof Error && error.message ? error.message : String(error),
1873
+ error instanceof RunnerError ? error.code : 'rebuild-failed',
1874
+ error
1875
+ );
1876
+ }
1877
+ );
1878
+ }
1879
+
1880
+ #live(generation: number): boolean {
1881
+ return this.#owned && !this.#stopped && this.#generation === generation;
1882
+ }
1883
+
1884
+ async #runRebuild(generation: number) {
1885
+ const backend = this.#registration.backend;
1886
+ await this.#quiesce(this.#ownerEpoch!);
1887
+ if (!this.#live(generation)) return;
1888
+ this.#ownerEpoch = this.#mintEpoch();
1889
+ this.status = { state: 'rebuilding', ownerEpoch: this.#ownerEpoch };
1890
+ this.#publishReadiness('rebuilding');
1891
+ this.#resetting = Promise.resolve(backend.reset!(this.#ownerEpoch));
1892
+ try {
1893
+ await this.#resetting;
1894
+ } finally {
1895
+ this.#resetting = undefined;
1896
+ }
1897
+ if (!this.#live(generation)) return;
1898
+ if (backend.getDurableCursor() !== undefined) throw new Error('backend kept a durable cursor after reset');
1899
+ const boundary = this.#captureBoundary();
1900
+ const options = this.#options;
1901
+ let chunk = this.#newChunk(true);
1902
+ let indexed = 0;
1903
+ for (const [tableId] of this.#registration.projections) {
1904
+ for (const record of this.#scanRecords!(tableId)) {
1905
+ if (this.#addScanRecord(chunk, tableId, record)) indexed++;
1906
+ // Filtered entries (tombstones, symbol keys) count against the turn too: a long run of them
1907
+ // must yield without delivering an empty chunk.
1908
+ if (
1909
+ chunk.batch.records.length >= options.maxChunkRecords ||
1910
+ chunk.batch.bytes >= options.maxChunkBytes ||
1911
+ options.now() - chunk.started >= options.maxMillisecondsPerTurn
1912
+ ) {
1913
+ if (chunk.batch.records.length > 0) {
1914
+ this.#noteChunkProjection(chunk);
1915
+ await this.#deliverRebuildChunk(chunk, generation);
1916
+ } else await new Promise<void>((resolve) => setImmediate(resolve));
1917
+ if (!this.#live(generation)) return;
1918
+ chunk = this.#newChunk(true);
1919
+ }
1920
+ }
1921
+ }
1922
+ this.#noteChunkProjection(chunk);
1923
+ chunk.batch.through = boundary;
1924
+ await this.#deliverRebuildChunk(chunk, generation);
1925
+ if (!this.#live(generation)) return;
1926
+ this.#rebuiltRecords = indexed;
1927
+ if (!this.#installCursor(boundary)) return;
1928
+ this.#boundaryPending = true;
1929
+ logger.info?.(`Rebuilt derived index '${backend.id}' from ${indexed} records; replaying what committed since`);
1930
+ }
1931
+
1932
+ #addScanRecord(chunk: Chunk, tableId: number, record: DerivedIndexScanRecord): DerivedIndexMutation | undefined {
1933
+ if (record.value == null || typeof record.recordId === 'symbol') return;
1934
+ const key = writeKeyId(record.recordId);
1935
+ let byRecord = chunk.resolved.get(tableId);
1936
+ if (!byRecord) chunk.resolved.set(tableId, (byRecord = new Map()));
1937
+ if (byRecord.has(key)) return;
1938
+ const mutation: DerivedIndexMutation = {
1939
+ tableId,
1940
+ recordId: record.recordId,
1941
+ logVersion: record.version,
1942
+ state: this.#project(chunk, tableId, record.value, record.version, record.size),
1943
+ };
1944
+ byRecord.set(key, mutation);
1945
+ chunk.batch.records.push(mutation);
1946
+ return mutation;
1947
+ }
1948
+
1949
+ async #deliverRebuildChunk(chunk: Chunk, generation: number) {
1950
+ while (true) {
1951
+ if (!this.#live(generation)) return;
1952
+ const result = this.#deliver(chunk.batch);
1953
+ if (result === undefined) {
1954
+ if (this.#live(generation)) throw new Error('rebuild delivery was rejected');
1955
+ return;
1956
+ }
1957
+ if (result === DERIVED_INDEX_ACCEPTED) break;
1958
+ this.#stalledSince ??= this.#options.now();
1959
+ await this.#waitForBackend();
1960
+ }
1961
+ this.#stalledSince = undefined;
1962
+ this.#noteAccepted(chunk.batch);
1963
+ await new Promise<void>((resolve) => setImmediate(resolve));
1964
+ }
1965
+
1966
+ #waitForBackend(): Promise<void> {
1967
+ if (this.#rebuildWakePending) {
1968
+ this.#rebuildWakePending = false;
1969
+ return Promise.resolve();
1970
+ }
1971
+ return new Promise<void>((resolve) => {
1972
+ const timer = setTimeout(() => this.#rebuildWaiter?.(), Math.max(1, this.#options.maxFlushAgeMilliseconds));
1973
+ timer.unref?.();
1974
+ this.#rebuildWaiter = () => {
1975
+ clearTimeout(timer);
1976
+ this.#rebuildWaiter = undefined;
1977
+ resolve();
1978
+ };
1979
+ });
1980
+ }
1981
+
1982
+ /**
1983
+ * The committed tail of every log, captured before the scan. A committed read is a contiguous
1984
+ * physical prefix (rocksdb-js advances `lastCommittedPosition` only to the earliest still-uncommitted
1985
+ * write), so nothing committed after this point can sit behind it: the scan covers everything up to
1986
+ * the tail and the replay from it covers everything after, including any reload marker committed
1987
+ * during the scan, which then demands its own rebuild. A log with no committed transaction is left
1988
+ * out of the cursor and read from its beginning, which it must still retain.
1989
+ */
1990
+ #captureBoundary(): DerivedIndexCursor {
1991
+ const boundary: DerivedIndexCursor = { format: 1, logs: {} };
1992
+ for (const logName of this.#logStore.rootStore.listLogs()) {
1993
+ let tail: number | undefined;
1994
+ const range = this.#logStore.getRange({ log: logName, start: 0 });
1995
+ for (const entry of range) if (entry.endTxn) tail = entry.txnLogKey;
1996
+ if (range.corruptFrameStop.breaks > 0 || range.failedLogs.size > 0)
1997
+ throw new RunnerError('log-corrupt', `transaction log '${logName}' cannot be read to its committed tail`);
1998
+ if (tail === undefined) {
1999
+ if (!this.#retainsBeginning(logName))
2000
+ throw new RunnerError(
2001
+ 'log-retention',
2002
+ `transaction log '${logName}' retains no committed transaction and has lost its beginning`
2003
+ );
2004
+ continue;
2005
+ }
2006
+ boundary.logs[logName] = tail;
2007
+ }
2008
+ return boundary;
2009
+ }
2010
+
2011
+ #rebuildFailed(reason: string, code: DerivedIndexReadinessReason, error?: unknown) {
2012
+ this.#rebuilding = false;
2013
+ this.#rebuildWaiter?.();
2014
+ this.#discardProgress();
2015
+ if (this.#rebuildAttempts >= this.#options.maxRebuildAttempts) {
2016
+ this.#becomeUnavailable(reason, code, error);
2017
+ return;
2018
+ }
2019
+ logger.error(
2020
+ `Derived index '${this.#registration.backend.id}' rebuild attempt ${this.#rebuildAttempts} failed: ${reason}`,
2021
+ error
2022
+ );
2023
+ this.status = { state: 'needs-rebuild', reason, ownerEpoch: this.#ownerEpoch };
2024
+ this.#publishReadiness('needs-rebuild', code);
2025
+ this.#rebuildRequested = true;
2026
+ if (this.#owned) this.#scheduleRebuild();
2027
+ }
2028
+
2029
+ /** One `shutdown(epoch)` per epoch, shared by a rebuild attempt and a release that overlap. */
2030
+ #quiesce(epoch: bigint): Promise<void> {
2031
+ if (this.#quiescing?.epoch === epoch) return this.#quiescing.promise;
2032
+ let promise: Promise<void>;
2033
+ try {
2034
+ promise = Promise.resolve(this.#registration.backend.shutdown(epoch));
2035
+ } catch (error) {
2036
+ promise = Promise.reject(error);
2037
+ }
2038
+ const quiescing = { epoch, promise, since: this.#options.now() };
2039
+ this.#quiescing = quiescing;
2040
+ const settle = () => {
2041
+ if (this.#quiescing === quiescing) this.#quiescing = undefined;
2042
+ };
2043
+ promise.then(settle, settle);
2044
+ return promise;
2045
+ }
2046
+
2047
+ #publishReadiness(state: DerivedIndexReadinessState, reason: DerivedIndexReadinessReason = 'none') {
2048
+ const { words } = this.#shared();
2049
+ if (state !== 'ready') Atomics.store(this.#shared().coverage, 0, 0n);
2050
+ // State last: a reader that sees the new state sees a reason and attempt count at least as new.
2051
+ Atomics.store(words, READINESS_REASON, READINESS_REASONS.indexOf(reason));
2052
+ Atomics.store(words, READINESS_ATTEMPTS, state === 'ready' ? 0 : this.#rebuildAttempts);
2053
+ Atomics.store(words, READINESS_STATE, READINESS_STATES.indexOf(state));
2054
+ }
2055
+
2056
+ #release() {
2057
+ if (!this.#owned) return;
2058
+ if (this.#idleTimer) {
2059
+ clearTimeout(this.#idleTimer);
2060
+ this.#idleTimer = undefined;
2061
+ }
2062
+ if (this.#rebuildTimer) {
2063
+ clearTimeout(this.#rebuildTimer);
2064
+ this.#rebuildTimer = undefined;
2065
+ }
2066
+ this.#owned = false;
2067
+ this.#rebuilding = false;
2068
+ this.#rebuildWaiter?.();
2069
+ if (this.#lagTimer) {
2070
+ clearTimeout(this.#lagTimer);
2071
+ this.#lagTimer = undefined;
2072
+ }
2073
+ this.#discardProgress();
2074
+ const backend = this.#registration.backend;
2075
+ const epoch = this.#ownerEpoch;
2076
+ this.#releasingSince = this.#options.now();
2077
+ const unlock = () => {
2078
+ this.#releasing = undefined;
2079
+ this.#releasingSince = undefined;
2080
+ try {
2081
+ this.#logStore.unlock(this.#lockKey);
2082
+ } catch (error) {
2083
+ logger.error(`Failed to release derived index runner '${backend.id}'`, error);
2084
+ }
2085
+ // A skip left standing by an absent or throwing `notify` swallows a peer's release instead.
2086
+ if (this.#readinessBuffer.notify) {
2087
+ this.#skipNextNotify = !this.#stopped;
2088
+ try {
2089
+ this.#readinessBuffer.notify();
2090
+ } catch (error) {
2091
+ this.#skipNextNotify = false;
2092
+ logger.warn?.(`Derived index '${backend.id}' could not notify peers of its release`, error);
2093
+ }
2094
+ }
2095
+ };
2096
+ const hold = (error: unknown) => {
2097
+ this.#releasing = undefined;
2098
+ this.#releasingSince = undefined;
2099
+ this.#heldLock = true;
2100
+ const reason = `backend shutdown failed; runner lock held: ${error instanceof Error ? error.message : String(error)}`;
2101
+ logger.error(`Derived index '${backend.id}' ${reason}`, error);
2102
+ this.#releaseFailure = new Error(reason, { cause: error });
2103
+ this.status = { state: 'unavailable', reason, ownerEpoch: epoch };
2104
+ this.#publishReadiness('unavailable', 'shutdown-failed');
2105
+ this.#admitWrites();
2106
+ };
2107
+ let flushed: void | Promise<void>;
2108
+ try {
2109
+ flushed = backend.flush('shutdown');
2110
+ } catch (error) {
2111
+ logger.warn?.(`Derived index '${backend.id}' shutdown flush request threw`, error);
2112
+ }
2113
+ const settling = Promise.allSettled([this.#resetting, flushed]).then(() => undefined);
2114
+ this.#releasing = settling.then(() => (epoch === undefined ? undefined : this.#quiesce(epoch))).then(unlock, hold);
2115
+ }
2116
+ }
2117
+
2118
+ function lastOpen(collected: CollectedTransaction[]): CollectedTransaction | undefined {
2119
+ const last = collected[collected.length - 1];
2120
+ return last && !last.complete ? last : undefined;
2121
+ }
2122
+
2123
+ type SharedReadinessBuffer = ArrayBufferLike & { notify?: () => void; cancel?: () => void };
2124
+
2125
+ function readinessBuffer(
2126
+ logStore: RocksTransactionLogStore,
2127
+ backendId: string,
2128
+ callback?: () => void
2129
+ ): SharedReadinessBuffer {
2130
+ return logStore.getUserSharedBuffer(
2131
+ `derived-index:${backendId}:readiness`,
2132
+ new ArrayBuffer(READINESS_BYTES),
2133
+ callback ? { callback } : undefined
2134
+ ) as SharedReadinessBuffer;
2135
+ }
2136
+
2137
+ type SharedViews = {
2138
+ words: Int32Array;
2139
+ epoch: BigInt64Array;
2140
+ coverage: BigInt64Array;
2141
+ };
2142
+
2143
+ function sharedViewsOf(buffer: ArrayBufferLike): SharedViews {
2144
+ return {
2145
+ words: new Int32Array(buffer, 0, READINESS_WORDS),
2146
+ epoch: new BigInt64Array(buffer, READINESS_EPOCH_OFFSET, 1),
2147
+ coverage: new BigInt64Array(buffer, READINESS_COVERAGE_OFFSET, 1),
2148
+ };
2149
+ }
2150
+
2151
+ const readinessViews = new WeakMap<object, Map<string, SharedViews>>();
2152
+
2153
+ function readReadiness({ words, epoch }: SharedViews): DerivedIndexReadiness {
2154
+ const state = READINESS_STATES[Atomics.load(words, READINESS_STATE)] ?? 'unknown';
2155
+ const reason = READINESS_REASONS[Atomics.load(words, READINESS_REASON)] ?? 'none';
2156
+ const readiness: DerivedIndexReadiness = {
2157
+ state,
2158
+ ownerEpoch: Atomics.load(epoch, 0),
2159
+ rebuildAttempts: Atomics.load(words, READINESS_ATTEMPTS),
2160
+ };
2161
+ if (reason !== 'none') readiness.reason = reason;
2162
+ return readiness;
2163
+ }
2164
+
2165
+ /** Read an index's shared readiness on any worker, without a registered runtime. */
2166
+ export function readDerivedIndexReadiness(
2167
+ logStore: RocksTransactionLogStore,
2168
+ backendId: string
2169
+ ): DerivedIndexReadiness {
2170
+ return readReadiness(getReadinessViews(logStore, backendId));
2171
+ }
2172
+
2173
+ function getReadinessViews(logStore: RocksTransactionLogStore, backendId: string): SharedViews {
2174
+ let byBackend = readinessViews.get(logStore);
2175
+ if (!byBackend) readinessViews.set(logStore, (byBackend = new Map()));
2176
+ let views = byBackend.get(backendId);
2177
+ if (!views) byBackend.set(backendId, (views = sharedViewsOf(readinessBuffer(logStore, backendId))));
2178
+ return views;
2179
+ }
2180
+
2181
+ function readCommittedPositions(
2182
+ logStore: RocksTransactionLogStore,
2183
+ names: Iterable<string> = logStore.rootStore.listLogs()
2184
+ ): DerivedIndexPositions | undefined {
2185
+ const positions: DerivedIndexPositions = Object.create(null);
2186
+ try {
2187
+ for (const name of names) {
2188
+ const stats = logStore.rootStore.useLog(name).getStats();
2189
+ // An earlier uncommitted transaction can hide later completed commits behind the readable prefix.
2190
+ const committed = stats.lastCommittedPosition;
2191
+ const next = stats.nextLogPosition;
2192
+ if (next.sequence === 0 && next.offset === 0) {
2193
+ // Opening an empty iterator seeds a header-position sentinel before any file has been written.
2194
+ positions[name] = null;
2195
+ continue;
2196
+ }
2197
+ if (committed ? committed.sequence !== next.sequence || committed.offset !== next.offset : next.offset !== 0)
2198
+ return;
2199
+ positions[name] = committed;
2200
+ }
2201
+ } catch {
2202
+ return;
2203
+ }
2204
+ return positions;
2205
+ }
2206
+
2207
+ export function sameDerivedIndexPositions(left: DerivedIndexPositions, right: DerivedIndexPositions): boolean {
2208
+ if (
2209
+ !left ||
2210
+ !right ||
2211
+ typeof left !== 'object' ||
2212
+ typeof right !== 'object' ||
2213
+ Array.isArray(left) ||
2214
+ Array.isArray(right) ||
2215
+ Object.keys(left).length !== Object.keys(right).length
2216
+ )
2217
+ return false;
2218
+ for (const name of Object.keys(left)) {
2219
+ if (!Object.hasOwn(right, name)) return false;
2220
+ const a = left[name];
2221
+ const b = right[name];
2222
+ if (a === null || b === null) {
2223
+ if (a !== b) return false;
2224
+ } else if (!a || !b || a.sequence !== b.sequence || a.offset !== b.offset) return false;
2225
+ }
2226
+ return true;
2227
+ }
2228
+
2229
+ export function readDerivedIndexCoverage(
2230
+ logStore: RocksTransactionLogStore,
2231
+ backendId: string,
2232
+ loadCursor: () => DerivedIndexCursor | undefined,
2233
+ maxLagMilliseconds: number
2234
+ ): DerivedIndexCoverage {
2235
+ const unknown: DerivedIndexCoverage = { state: 'unknown', maxLagMilliseconds };
2236
+ try {
2237
+ const views = getReadinessViews(logStore, backendId);
2238
+ const readiness = readReadiness(views);
2239
+ if (readiness.state !== 'ready') return unknown;
2240
+ const time = Atomics.load(views.coverage, 0);
2241
+ if (time > 0n) {
2242
+ const age = Number(derivedIndexTime(logStore.rootStore) - time) / 1e6;
2243
+ if (age >= 0) {
2244
+ unknown.lagUpperBoundMilliseconds = age;
2245
+ if (maxLagMilliseconds > 0 && age <= maxLagMilliseconds) return { ...unknown, state: 'bounded' };
2246
+ }
2247
+ }
2248
+ const cursor = loadCursor();
2249
+ if (!isValidCursor(cursor) || !cursor.coverage) return unknown;
2250
+ const positions = readCommittedPositions(logStore);
2251
+ const after = readReadiness(views);
2252
+ if (
2253
+ positions &&
2254
+ after.state === 'ready' &&
2255
+ after.ownerEpoch === readiness.ownerEpoch &&
2256
+ sameDerivedIndexPositions(cursor.coverage, positions)
2257
+ )
2258
+ return { state: 'current', maxLagMilliseconds, lagUpperBoundMilliseconds: 0 };
2259
+ } catch {
2260
+ // Failure to read coverage is not evidence that the native graph needs rebuilding.
2261
+ }
2262
+ return unknown;
2263
+ }
2264
+
2265
+ function isValidCursor(cursor: DerivedIndexCursor | undefined): cursor is DerivedIndexCursor {
2266
+ if (
2267
+ !cursor ||
2268
+ typeof cursor !== 'object' ||
2269
+ cursor.format !== 1 ||
2270
+ !cursor.logs ||
2271
+ typeof cursor.logs !== 'object' ||
2272
+ Array.isArray(cursor.logs)
2273
+ )
2274
+ return false;
2275
+ for (const timestamp of Object.values(cursor.logs)) {
2276
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return false;
2277
+ }
2278
+ return true;
2279
+ }
2280
+
2281
+ function cloneCursor(cursor: DerivedIndexCursor): DerivedIndexCursor {
2282
+ return { format: 1, logs: { ...cursor.logs } };
2283
+ }
2284
+
2285
+ function sameCursor(left: DerivedIndexCursor | undefined, right: DerivedIndexCursor | undefined): boolean {
2286
+ if (!isValidCursor(left) || !isValidCursor(right)) return false;
2287
+ const leftNames = Object.keys(left.logs);
2288
+ const rightNames = Object.keys(right.logs);
2289
+ if (leftNames.length !== rightNames.length) return false;
2290
+ for (const name of leftNames) if (left.logs[name] !== right.logs[name]) return false;
2291
+ return true;
2292
+ }