@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.
- package/bin/cliOperations.ts +12 -0
- package/bin/restart.ts +66 -6
- package/components/Application.ts +1134 -112
- package/components/DESIGN.md +421 -0
- package/components/OptionsWatcher.ts +368 -102
- package/components/Scope.ts +20 -1
- package/components/componentLoader.ts +43 -4
- package/components/deploymentOperations.ts +4 -1
- package/components/deploymentRecorder.ts +9 -2
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/components/operations.js +284 -52
- package/components/operationsValidation.js +49 -2
- package/components/packageComponent.ts +25 -1
- package/components/requestRestart.ts +11 -0
- package/config/DESIGN.md +306 -0
- package/config/RootConfigWatcher.ts +191 -37
- package/config/configReadRetry.ts +62 -0
- package/config/configUtils.ts +78 -26
- package/config/parseConfigFile.ts +34 -0
- package/config/readConfigFileSync.ts +44 -0
- package/config/watcherArming.ts +59 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/bin/cliOperations.js +13 -0
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/restart.js +42 -6
- package/dist/bin/restart.js.map +1 -1
- package/dist/components/Application.d.ts +104 -9
- package/dist/components/Application.js +954 -102
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +4 -1
- package/dist/components/OptionsWatcher.js +378 -104
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.js +15 -1
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +35 -3
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentOperations.js +4 -1
- package/dist/components/deploymentOperations.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +4 -2
- package/dist/components/deploymentRecorder.js +1 -0
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/components/operations.d.ts +28 -0
- package/dist/components/operations.js +263 -54
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +48 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/packageComponent.js +24 -0
- package/dist/components/packageComponent.js.map +1 -1
- package/dist/components/requestRestart.d.ts +1 -0
- package/dist/components/requestRestart.js +7 -0
- package/dist/components/requestRestart.js.map +1 -1
- package/dist/config/RootConfigWatcher.d.ts +2 -0
- package/dist/config/RootConfigWatcher.js +189 -35
- package/dist/config/RootConfigWatcher.js.map +1 -1
- package/dist/config/configReadRetry.d.ts +8 -0
- package/dist/config/configReadRetry.js +62 -0
- package/dist/config/configReadRetry.js.map +1 -0
- package/dist/config/configUtils.d.ts +10 -9
- package/dist/config/configUtils.js +63 -27
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/parseConfigFile.d.ts +4 -0
- package/dist/config/parseConfigFile.js +35 -0
- package/dist/config/parseConfigFile.js.map +1 -0
- package/dist/config/readConfigFileSync.d.ts +1 -0
- package/dist/config/readConfigFileSync.js +47 -0
- package/dist/config/readConfigFileSync.js.map +1 -0
- package/dist/config/watcherArming.d.ts +15 -0
- package/dist/config/watcherArming.js +59 -0
- package/dist/config/watcherArming.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +3 -0
- package/dist/resources/DatabaseTransaction.d.ts +25 -0
- package/dist/resources/DatabaseTransaction.js +224 -18
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +2 -1
- package/dist/resources/LMDBTransaction.js +22 -3
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.js +22 -6
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/RecordEncoder.d.ts +1 -1
- package/dist/resources/RecordEncoder.js +46 -10
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +97 -13
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -0
- package/dist/resources/RocksIndexStore.js +2 -1
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
- package/dist/resources/RocksTransactionLogStore.js +104 -33
- package/dist/resources/RocksTransactionLogStore.js.map +1 -1
- package/dist/resources/Table.d.ts +58 -7
- package/dist/resources/Table.js +1143 -348
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +49 -16
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +170 -0
- package/dist/resources/auditStore.js +457 -11
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/dataLoader.js +3 -4
- package/dist/resources/dataLoader.js.map +1 -1
- package/dist/resources/databases.d.ts +16 -13
- package/dist/resources/databases.js +625 -177
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRegistry.d.ts +5 -0
- package/dist/resources/derivedIndexRegistry.js +68 -0
- package/dist/resources/derivedIndexRegistry.js.map +1 -0
- package/dist/resources/derivedIndexRuntime.d.ts +215 -0
- package/dist/resources/derivedIndexRuntime.js +2027 -0
- package/dist/resources/derivedIndexRuntime.js.map +1 -0
- package/dist/resources/graphql.js +3 -2
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
- package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
- package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
- package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
- package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/nodeIdMapping.d.ts +5 -0
- package/dist/resources/nodeIdMapping.js +49 -0
- package/dist/resources/nodeIdMapping.js.map +1 -1
- package/dist/resources/recordLock.d.ts +47 -4
- package/dist/resources/recordLock.js +138 -7
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/recordLockCoordinator.d.ts +557 -0
- package/dist/resources/recordLockCoordinator.js +2597 -0
- package/dist/resources/recordLockCoordinator.js.map +1 -0
- package/dist/resources/replayLogs.js +5 -0
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/replicatedApplyFailure.d.ts +16 -0
- package/dist/resources/replicatedApplyFailure.js +63 -0
- package/dist/resources/replicatedApplyFailure.js.map +1 -0
- package/dist/resources/scheduler/scheduler.js +3 -3
- package/dist/resources/scheduler/scheduler.js.map +1 -1
- package/dist/resources/search.d.ts +10 -4
- package/dist/resources/search.js +160 -40
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/tracked.d.ts +5 -1
- package/dist/resources/tracked.js +74 -23
- package/dist/resources/tracked.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +9 -4
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +39 -5
- package/dist/server/REST.js.map +1 -1
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +34 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/Headers.d.ts +2 -0
- package/dist/server/serverHelpers/Headers.js +6 -0
- package/dist/server/serverHelpers/Headers.js.map +1 -1
- package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
- package/dist/server/serverHelpers/Request.d.ts +5 -10
- package/dist/server/serverHelpers/Request.js +38 -136
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
- package/dist/server/serverHelpers/contentTypes.js +189 -15
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +110 -20
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/server/storageReclamation.js +1 -1
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/isolatedApplications.d.ts +47 -0
- package/dist/server/threads/isolatedApplications.js +171 -0
- package/dist/server/threads/isolatedApplications.js.map +1 -0
- package/dist/server/threads/logRotationTransport.d.ts +1 -0
- package/dist/server/threads/logRotationTransport.js +33 -0
- package/dist/server/threads/logRotationTransport.js.map +1 -0
- package/dist/server/threads/manageThreads.d.ts +64 -6
- package/dist/server/threads/manageThreads.js +261 -12
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.d.ts +1 -0
- package/dist/server/threads/socketRouter.js +196 -13
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +30 -7
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +24 -0
- package/dist/utility/errors/hdbError.js +58 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +2 -0
- package/dist/utility/hdbTerms.js +2 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.js +218 -38
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
- package/dist/utility/logging/logGenerationCoordinator.js +184 -0
- package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
- package/dist/utility/logging/logRotation.d.ts +46 -0
- package/dist/utility/logging/logRotation.js +365 -0
- package/dist/utility/logging/logRotation.js.map +1 -0
- package/dist/utility/logging/logRotator.d.ts +1 -1
- package/dist/utility/logging/logRotator.js +172 -92
- package/dist/utility/logging/logRotator.js.map +1 -1
- package/dist/utility/npmUtilities.js +6 -4
- package/dist/utility/npmUtilities.js.map +1 -1
- package/dist/utility/watcherFallback.d.ts +0 -45
- package/dist/utility/watcherFallback.js +1 -125
- package/dist/utility/watcherFallback.js.map +1 -1
- package/dist/validation/configValidator.js +6 -3
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +9 -0
- package/json/systemSchema.json +3 -0
- package/npm-shrinkwrap.json +177 -87
- package/package.json +13 -5
- package/resources/DESIGN.md +689 -19
- package/resources/DatabaseTransaction.ts +230 -17
- package/resources/LMDBTransaction.ts +21 -3
- package/resources/PrimaryRocksDatabase.ts +20 -7
- package/resources/RecordEncoder.ts +52 -10
- package/resources/Resource.ts +97 -13
- package/resources/ResourceInterface.ts +8 -0
- package/resources/RocksIndexStore.ts +2 -1
- package/resources/RocksTransactionLogStore.ts +111 -31
- package/resources/Table.ts +1273 -396
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +50 -17
- package/resources/auditStore.ts +460 -11
- package/resources/crdt.ts +22 -0
- package/resources/dataLoader.ts +3 -4
- package/resources/databases.ts +611 -147
- package/resources/derivedIndexRegistry.ts +56 -0
- package/resources/derivedIndexRuntime.ts +2292 -0
- package/resources/graphql.ts +3 -2
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/hnswDerivedIndex.ts +531 -0
- package/resources/indexes/hnswPlaneBinding.ts +174 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/nodeIdMapping.ts +50 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLock.ts +173 -7
- package/resources/recordLockCoordinator.ts +3082 -0
- package/resources/replayLogs.ts +5 -0
- package/resources/replicatedApplyFailure.ts +77 -0
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/scheduler/scheduler.ts +4 -4
- package/resources/search.ts +169 -49
- package/resources/tracked.ts +73 -22
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +9 -4
- package/server/DESIGN.md +275 -0
- package/server/REST.ts +42 -6
- package/server/http.ts +34 -2
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/Headers.ts +5 -1
- package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
- package/server/serverHelpers/Request.ts +33 -131
- package/server/serverHelpers/contentTypes.ts +188 -15
- package/server/serverHelpers/serverUtilities.ts +157 -27
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/server/storageReclamation.ts +2 -2
- package/server/threads/isolatedApplications.ts +157 -0
- package/server/threads/logRotationTransport.ts +40 -0
- package/server/threads/manageThreads.js +254 -12
- package/server/threads/socketRouter.ts +217 -11
- package/server/threads/threadServer.js +30 -7
- package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/errors/hdbError.ts +54 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/logging/harper_logger.ts +210 -30
- package/utility/logging/logGenerationCoordinator.ts +196 -0
- package/utility/logging/logRotation.ts +367 -0
- package/utility/logging/logRotator.ts +196 -91
- package/utility/npmUtilities.ts +6 -4
- package/utility/watcherFallback.ts +0 -122
- package/validation/configValidator.ts +6 -3
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
# resources/indexes/ — Design notes
|
|
2
|
+
|
|
3
|
+
The derived-index runtime and the HNSW vector index (JS graph and native plane).
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `derivedIndexRuntime.ts`, `HierarchicalNavigableSmallWorld.ts`, `hnswDerivedIndex.ts`, `hnswPlaneBinding.ts`, or vector query planning.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Graph size on the HNSW query path must come from node ids (`resources/indexes/HierarchicalNavigableSmallWorld.ts`)
|
|
12
|
+
|
|
13
|
+
The ef auto-scale needs to know how big the graph is, on every query. Two sources that look right
|
|
14
|
+
are not.
|
|
15
|
+
|
|
16
|
+
`getKeysCount()` on a RocksDB store is an exact key scan, so it is O(N): measured at 13 ms per call
|
|
17
|
+
at 10K keys, 128 ms at 100K, ~1 s at 500K. Calling it per query puts a linear-in-corpus-size term in
|
|
18
|
+
front of every vector search — 34% of query latency at 20K vectors on the real table stack.
|
|
19
|
+
|
|
20
|
+
RocksDB's `rocksdb.estimate-num-keys` property is O(1) and looks like the obvious replacement, but it
|
|
21
|
+
counts entries across memtable and SST files without reconciling overwrites. Building an HNSW graph
|
|
22
|
+
rewrites each node many times as its neighbours change, so on a real index it reads far high: 37,775
|
|
23
|
+
for a 2,000-record table whose exact key count is 4,001, and worse after deletes. It reads exact on a
|
|
24
|
+
fresh store with simple puts, so it validates clean in isolation and only misleads on a real index.
|
|
25
|
+
|
|
26
|
+
Node ids are the sound source. They are allocated monotonically from a `getUserSharedBuffer` counter,
|
|
27
|
+
so the counter (or one reverse seek to the largest id) gives the node count in O(1), unaffected by
|
|
28
|
+
how many times a node has been rewritten. Deletes leave it reading high until a rebuild, which only
|
|
29
|
+
makes ef slightly generous. A file-primary (`nativePlane`) index keeps no node-id keys in its store
|
|
30
|
+
at all — the plane slot carries the primary key — so its count is the plane's id high-water, which
|
|
31
|
+
reads the same way (allocation high-water, generous after deletes until a rebuild).
|
|
32
|
+
|
|
33
|
+
Note the unit: the JS index store holds two keys per record — the graph node and the primary-key
|
|
34
|
+
mapping — so a key count is twice the node count. `AUTO_EF_REF` is expressed in nodes for that
|
|
35
|
+
reason, and any change between the two units has to move it to keep the resolved ef the same.
|
|
36
|
+
|
|
37
|
+
## HNSW layers above 0 are for routing only, and must be searched greedily
|
|
38
|
+
|
|
39
|
+
Each layer above 0 exists to hand the next layer down an entry point: `search()` and `index()` both
|
|
40
|
+
take `results[0]` and discard the rest. Searching them at the full `ef` therefore buys nothing and
|
|
41
|
+
costs work proportional to the layer's population rather than to ef — layer 1 holds ~N/M nodes, and
|
|
42
|
+
at ef 512 a query visited ~95% of it. That is a second linear-in-N term: upper-layer visits per query
|
|
43
|
+
grew 342 → 2,421 across 5K → 41K vectors on real embeddings, and reached 75% of query time at 100K. Greedy descent
|
|
44
|
+
(`ROUTING_EF`) is what standard HNSW does. Measured against the same graphs searched at the full `ef`
|
|
45
|
+
on every layer, across 16 (size, `ef`) points on a held-out real-embedding corpus, the worst
|
|
46
|
+
recall@10 change was -0.002 — one displaced neighbour at a single point — and 0.000 everywhere else.
|
|
47
|
+
|
|
48
|
+
The connection-building pass in `index()` is not routing — it selects the edges that get stored — so
|
|
49
|
+
it keeps `efConstruction`.
|
|
50
|
+
|
|
51
|
+
The insert-side change is the one that alters stored graphs, recoverable only by a reindex, so it was
|
|
52
|
+
measured separately (`benchmarks/hnsw-scale.js --build-upper-ef=100` restores the previous
|
|
53
|
+
index-time descent). At 20,000 real 768-dim embeddings with identical corpus and level assignments,
|
|
54
|
+
the two builds were indistinguishable on every metric measured — same recall at each `ef`, same visit
|
|
55
|
+
counts, same mean layer-0 degree — and the greedy build was 1.28x faster. That is consistent with the
|
|
56
|
+
graphs being identical, though equal metrics do not prove it. It is the expected result either way:
|
|
57
|
+
the upper layers are sparse enough that a greedy walk reaches the same entry point, which is why
|
|
58
|
+
standard HNSW descends this way.
|
|
59
|
+
|
|
60
|
+
Greedy-equals-full is statistical, not per-graph: rare level layouts route to a different layer-0
|
|
61
|
+
entry point and displace the tail of the top-k (~2-3% of random 600-node graphs in the unit test's
|
|
62
|
+
corpus). Tests that assert exact result-set equality across search strategies must therefore pin the
|
|
63
|
+
graph: level assignment draws from the instance's `random` property (a test seam defaulting to
|
|
64
|
+
`Math.random`), which the routing test replaces with a seeded PRNG. One pinned graph samples the
|
|
65
|
+
property once, so that test sweeps a fixed list of seeds, each verified non-divergent when the list
|
|
66
|
+
was written — a seed that starts diverging after an intentional index change is a re-pin, not
|
|
67
|
+
necessarily a routing regression.
|
|
68
|
+
|
|
69
|
+
## `efConstruction` and the search-`ef` ceiling both auto-scale with the graph
|
|
70
|
+
|
|
71
|
+
The connection-building pass selects each node's stored edges from a candidate list of
|
|
72
|
+
`efConstruction` entries. Held at a constant (100) while the corpus grows, edge quality erodes in a
|
|
73
|
+
way no search-side setting can compensate: at 1M nodes (768-dim, int8, calibrated hard corpus)
|
|
74
|
+
recall@10 fell to 0.935 and sweeping the search `ef` from 512 to 1536 only reached 0.957 raw / 0.967
|
|
75
|
+
set at 4.7x the latency — the missing neighbours were not deep in the candidate list, they were
|
|
76
|
+
unreachable. Rebuilding the identical corpus (same seed, same level assignments) with
|
|
77
|
+
`efConstruction` 200 restored 0.985/0.997 and made queries _faster_ at the same `ef` (3,110 nodes
|
|
78
|
+
visited vs 3,948 — better-selected edges route more directly). Quantization contributed ~1.5 points
|
|
79
|
+
(float32 rebuild: 0.952); construction quality was the dominant term. Full sweep in #2180.
|
|
80
|
+
|
|
81
|
+
So when the schema does not configure `efConstruction`, it scales as `AUTO_EF_BASE * sqrt(nodes /
|
|
82
|
+
AUTO_EFC_REF)`, capped at `AUTO_EFC_MAX`. The healthy write path reads the count directly from the
|
|
83
|
+
shared id counter: one atomic load with no memo lag during bulk ingest. If an update-only worker
|
|
84
|
+
cannot attach that counter, it warns once, falls back to the memoized reverse seek, and retries the
|
|
85
|
+
attach after the memo TTL; a new insert still requires the shared counter rather than risking ids
|
|
86
|
+
from a private counter. Scaling starts at 250K nodes: efC 100 held recall through 500K (0.978), so
|
|
87
|
+
smaller graphs — the common case — build exactly as before. The sqrt shape mirrors the search-side
|
|
88
|
+
scale; the cost is build time (1.77x at 1M for efC 200), paid only by tables that actually grow
|
|
89
|
+
large, and partly returned as cheaper queries.
|
|
90
|
+
|
|
91
|
+
An explicit `efConstruction` stays authoritative and is structural, so changing it triggers a full
|
|
92
|
+
index rebuild. It also seeds the search `ef`: setting `efConstruction: 100` alone cuts query effort
|
|
93
|
+
to 100. Retaining the former large-graph search default while opting out of build scaling requires
|
|
94
|
+
an explicit `efConstructionSearch` as well (512 after the former auto-scale reached its plateau).
|
|
95
|
+
There is currently no "pinned build, auto search" combination.
|
|
96
|
+
|
|
97
|
+
The search side scales past its old plateau for the same reason. `AUTO_EF_MAX` (512, pinned from
|
|
98
|
+
~13K nodes) was calibrated when layers above 0 were searched at the full `ef`, which made large efs
|
|
99
|
+
cost seconds; after the greedy-descent fix the same headroom costs tens of milliseconds (ef 1024 at
|
|
100
|
+
5M nodes: ~45ms p50), and holding the pin leaves measured recall on the table — set-recall at a
|
|
101
|
+
pinned 512 on well-built graphs decays 0.997 → 0.955 → 0.935 across 1M/2M/5M. So past
|
|
102
|
+
`AUTO_EF_LARGE_REF` (1M nodes, where 512 was last measured sufficient) the scale resumes from the
|
|
103
|
+
plateau — `512 * sqrt(nodes / 1M)` — up to `AUTO_EF_CEILING` (2048, binding at ~16M). The 5M point
|
|
104
|
+
resolves 1,145, bracketed by the measured ef-1024 sweep there (0.985 set). The default's query
|
|
105
|
+
latency therefore grows as sqrt(N) on large tables; that is the recall-first trade chosen here, and
|
|
106
|
+
apps preferring latency pin `efConstructionSearch` or a per-query `ef`. The filtered-traversal
|
|
107
|
+
budget (`maxVisits`, #1241) deliberately does not follow the second regime: each budgeted visit is
|
|
108
|
+
a synchronous record load plus predicate evaluation, so an auto-scaled ef's budget contribution
|
|
109
|
+
stays capped at `AUTO_EF_MAX` — the recall decision and the filtered-scan bound are separate
|
|
110
|
+
decisions, and an explicit ef (per-query or schema) still raises the budget for callers who own
|
|
111
|
+
the cost. Both ceilings are finite on
|
|
112
|
+
purpose: total build work grows as N^1.5 under sqrt scaling, and past roughly tens of millions of
|
|
113
|
+
nodes per graph, sharded medium graphs beat one huge graph on build and query cost alike — scaling
|
|
114
|
+
the constants further is the wrong tool there.
|
|
115
|
+
|
|
116
|
+
Two caveats are accepted deliberately, both inherited from the count being a lifetime high-water
|
|
117
|
+
mark of allocated node ids rather than a live count. First, churn: a table that deletes heavily
|
|
118
|
+
(TTL eviction, delete-and-reinsert ingest) reads high forever, so its build-side efC can sit at the
|
|
119
|
+
cap while the live graph is small. The 6–7x build-time extrapolation applies to a comparably large
|
|
120
|
+
graph; it is not a bound for a small rolling window. When efC exceeds the live graph size, the
|
|
121
|
+
candidate list cannot fill and an insert can traverse a large fraction of the graph before storing
|
|
122
|
+
only `M << 1` edges. This wastes throughput without improving recall. The search side accepted the
|
|
123
|
+
same over-count as "slightly generous ef" on an opt-in read path; the write path inherits it as a
|
|
124
|
+
known cost until a live count exists (tracked follow-up). Second, ramp history: nodes indexed before
|
|
125
|
+
the graph crossed a scale threshold keep their original edges — the scale applies to inserts from
|
|
126
|
+
that point on. A reindex in a live process rebuilds roughly uniformly (the id counter keeps its
|
|
127
|
+
high-water mark), but a reindex after a restart re-seeds the counter from the largest id in the
|
|
128
|
+
rebuilding store and therefore repeats the ramp — its first 250K nodes rebuild at the base efC.
|
|
129
|
+
Later inserts add reverse edges to older nodes, but a default-ramp 1M build has not been compared
|
|
130
|
+
directly with the uniform-200 A/B. The larger default-ramp runs reached 0.988 set-recall at 2M and
|
|
131
|
+
0.985 at 5M when searched at ef 1024, which shows that the measured neighbours remained reachable
|
|
132
|
+
at those sizes without proving uniform convergence.
|
|
133
|
+
|
|
134
|
+
Deletes have a separate tail-latency cost: connectivity repair can synchronously reinsert an orphan
|
|
135
|
+
and up to 256 nodes from a severed island. Those reinserts use the current auto-scaled efC, so the
|
|
136
|
+
per-insert build multiplier can land hundreds of times within one delete.
|
|
137
|
+
|
|
138
|
+
## An approximate index returns at most `ef` rows, so `limit` has to reach it
|
|
139
|
+
|
|
140
|
+
Layer 0 keeps at most `ef` candidates, and ef resolves from the auto-scale, not from the query. A
|
|
141
|
+
`limit` above it came back short with no error: with the 512 cap no vector query could return more
|
|
142
|
+
than 512 rows however large the limit, and `{offset: 250, limit: 200}` returned zero rows, so
|
|
143
|
+
paginating a vector search past the first page returned nothing. `searchByIndex` threads the query's
|
|
144
|
+
`offset + limit` to the custom index as `minResults`, which widens the candidate list to cover the
|
|
145
|
+
request. Any future approximate index needs the same plumbing.
|
|
146
|
+
|
|
147
|
+
Two bounds keep that from becoming a new problem. `ef` drives a synchronous traversal that holds
|
|
148
|
+
every admitted candidate in a sorted array with an O(len) insert, so a limit-derived `ef` is capped
|
|
149
|
+
at `LIMIT_EF_MAX`; without it, ordinary deep pagination (`offset` in the millions) would walk the
|
|
150
|
+
whole graph on the event loop, which is worse than the truncation being fixed. And schema-level or
|
|
151
|
+
per-query `ef` values stay authoritative: each is an explicit cost ceiling, so it bounds the result
|
|
152
|
+
set rather than being raised by the limit. Only automatically scaled indexes widen toward
|
|
153
|
+
`LIMIT_EF_MAX` to satisfy a larger bounded request.
|
|
154
|
+
|
|
155
|
+
`LIMIT_EF_MAX` is the _only_ bound on the widening — deliberately not also the graph size. Clamping
|
|
156
|
+
there is tempting and costs more than it saves: the memoized size reads low while a table grows, so
|
|
157
|
+
it truncates the limit it was supposed to honour, and resolving a size exact enough to clamp against
|
|
158
|
+
puts a store lookup back on every query whose `limit` exceeds the table — the linear-in-N term this
|
|
159
|
+
whole change removed, reintroduced in miniature. An `ef` above the node count is free anyway: the
|
|
160
|
+
traversal is bounded by the nodes it can reach, so it ends at the graph, not at `ef`.
|
|
161
|
+
|
|
162
|
+
The filter budget deliberately does not follow a limit-derived `ef`. It is computed from the `ef`
|
|
163
|
+
the index resolved for itself, with an automatically scaled `ef` capped at `AUTO_EF_MAX` before it is
|
|
164
|
+
multiplied by `filterExpansion`; explicit schema or per-query `ef` values remain authoritative.
|
|
165
|
+
Multiplying the budget by a caller's `limit` would turn a filtered vector query into a record-loading
|
|
166
|
+
scan wearing an index's clothes.
|
|
167
|
+
|
|
168
|
+
Paging a vector search is best-effort, not a stable partition. Each page re-runs the approximate
|
|
169
|
+
search at a different `ef` (`offset 0, limit 250` resolves 250; `offset 250, limit 200` resolves 450),
|
|
170
|
+
and an HNSW candidate set at a larger `ef` is not guaranteed to be an ordered superset of the smaller
|
|
171
|
+
one, so a record can repeat across pages or be skipped. Honoring `limit` fixes the "second page is
|
|
172
|
+
empty" defect; it does not make offsets a cursor. Callers who need stability should fetch one page
|
|
173
|
+
large enough for the whole result set, or pin an explicit `ef`.
|
|
174
|
+
|
|
175
|
+
One consumer is still calibrated in index-store keys rather than nodes: `estimateCountAsSort`, the
|
|
176
|
+
planner's cost estimate for a vector sort. It is scaled by `INDEX_KEYS_PER_NODE` so the count-source
|
|
177
|
+
unit switch does not shift the estimate on its own. The ef term remains the configured search value,
|
|
178
|
+
not the runtime auto-scaled value, so the planner increasingly underestimates vector traversal cost
|
|
179
|
+
as an automatically scaled graph grows.
|
|
180
|
+
|
|
181
|
+
## Derived-index runtime: committed-log delivery to native index backends (`resources/derivedIndexRuntime.ts`)
|
|
182
|
+
|
|
183
|
+
A derived index (the native HNSW plane, a future Tantivy full-text index) is a materialized view
|
|
184
|
+
that lives outside the record transaction: its apply is native, costs 0.2–1.4 ms per mutation, and
|
|
185
|
+
its durability barrier is an `msync` or a segment publish, none of which belong on the commit path.
|
|
186
|
+
The runtime is the one Harper-side implementation of the delivery protocol in harper#2489: **the
|
|
187
|
+
transaction log is the durable fact, a commit only wakes a runner, and every backend resumes from an
|
|
188
|
+
exact cursor into that log.** There is deliberately no second delivery fact — no transactional
|
|
189
|
+
dirty-key outbox, no `aftercommit` staging — because a second durable write per indexed mutation, a
|
|
190
|
+
cleanup protocol for it, and a new column family for every audited table would still not let an
|
|
191
|
+
engine-specific native file commit atomically with RocksDB, so the cursor protocol would be needed
|
|
192
|
+
anyway.
|
|
193
|
+
|
|
194
|
+
### Invariants
|
|
195
|
+
|
|
196
|
+
1. Only committed entries are delivered; the runtime never reads uncommitted log entries.
|
|
197
|
+
2. One worker runs a given backend at a time (a process-wide `tryLock` per backend). The owner
|
|
198
|
+
merges every physical log into one serial stream and keeps a cursor per log.
|
|
199
|
+
3. A cursor is `{ format: 1, logs: { <log name>: <completed transaction timestamp> } }`. It advances
|
|
200
|
+
only at `endTxn` and only after the backend's durability barrier covers the whole transaction.
|
|
201
|
+
4. Live delivery and restart use the same cursor validation, iterator and dispatch code. A lost wake
|
|
202
|
+
or a full backend queue delays indexing; it cannot skip durable log work.
|
|
203
|
+
5. Resume proves every saved log position with `exactStart`. A missing, incomplete or duplicate
|
|
204
|
+
boundary condemns the generation; approximate resume is not permitted.
|
|
205
|
+
6. The runtime resolves the current primary entry once per changed record and projects only the
|
|
206
|
+
registered attributes. Backends never see the log entry's body: a conflict retry can re-resolve a
|
|
207
|
+
record after its log payload was staged, so the entry is identity and version evidence, not state.
|
|
208
|
+
7. No exception from log iteration, primary reads, projection, backend calls or unlock callbacks
|
|
209
|
+
escapes the scheduled drain or interferes with subscriptions and replication.
|
|
210
|
+
8. Registration adds nothing to the commit path except a local-only eviction marker for registered
|
|
211
|
+
caching tables (below). No `aftercommit` listener, no retained `AuditRecord`s, no awaited work.
|
|
212
|
+
|
|
213
|
+
### Ownership and wake-up
|
|
214
|
+
|
|
215
|
+
Each worker holds one `DerivedIndexRuntime` per RocksDB root store, with the same schema-derived
|
|
216
|
+
registrations on every worker; the drain is lock-elected, registration is not. The runtime listens
|
|
217
|
+
to the root store's `committed` event and coalesces wakes through `setImmediate`. The winner keeps a
|
|
218
|
+
reusable aggregate iterator (`RocksTransactionLogStore.getRange` with `startByLog`, `exactStart`,
|
|
219
|
+
`resumeAfterExactStart`, `includeLogName`) and drains for bounded count, bytes and wall time per
|
|
220
|
+
turn. Ownership is sticky: the lock is not one record writers take, so holding it across turns
|
|
221
|
+
delays no commit, and it is released only after an idle grace period once durable progress equals
|
|
222
|
+
offered progress. The intended wake for a waiting runner is the lock's own unlock callback
|
|
223
|
+
(`tryLock(key, onUnlocked)`): one primitive, and a holder that dies releases natively and wakes the
|
|
224
|
+
waiters the same way. **Temporarily** the lock is taken without one: the pinned rocksdb-js queues
|
|
225
|
+
that callback as a thread-safe function of the caller's env, and on Node 22 one left behind by a
|
|
226
|
+
worker that was `terminate()`d aborts the process when another thread unlocks (Harper's thread
|
|
227
|
+
manager terminates workers on restart). Until the pin includes the fix (HarperFast/rocksdb-js#849),
|
|
228
|
+
successors are woken by the releasing owner's `notify()` on the readiness buffer and, for an owner
|
|
229
|
+
that died without releasing, by a retry timer (`lockRetryMilliseconds`, 5 s by default); the
|
|
230
|
+
releasing runner ignores the one notification it caused, and a runner already parked on that timer
|
|
231
|
+
does not re-probe the lock on commit wakes. This is a workaround with a tracked revert — the callback
|
|
232
|
+
path is simpler and picks up a dead owner immediately.
|
|
233
|
+
|
|
234
|
+
Configuration-level database aliases can load several table classes over the same physical audit
|
|
235
|
+
store, index column family and plane file. They must not run that backend more than once. Registration
|
|
236
|
+
therefore hands the backend to the newest class generation: it synchronously retires the predecessor,
|
|
237
|
+
installs the new class's index handle, projection, table id and lag policy, and lets the native lock keep
|
|
238
|
+
the successor idle until the predecessor releases ownership. A normal class cleanup only retires its own
|
|
239
|
+
generation. `dropTable()` is the destructive exception: it fences the immutable table id, retires every
|
|
240
|
+
registered backend for that generation (including settlement chains owned by another alias), and checks
|
|
241
|
+
that the catalog still names that table id before deleting name-keyed storage. A same-name recreation can
|
|
242
|
+
therefore register its new id without being retired by a stale alias.
|
|
243
|
+
|
|
244
|
+
Transaction timestamps are unique per physical log but **not monotone in physical order**
|
|
245
|
+
(`TransactionLogStore::writeBatch` only advances `latestTimestamp` when the batch's is greater), so
|
|
246
|
+
the runtime never compares timestamps to decide progress or retention. Resume is exact-start: find
|
|
247
|
+
the transaction, iterate after it. Repeat detection is a per-log set of completed timestamps
|
|
248
|
+
retained since the durable cursor. A log the cursor does not name is read from its beginning only
|
|
249
|
+
if it still retains it — `fileCount === 0 || oldestSequenceNumber === 1`; rocksdb-js reports
|
|
250
|
+
`oldestSequenceNumber: 0` for a log that has never written a file — otherwise the generation is
|
|
251
|
+
condemned.
|
|
252
|
+
|
|
253
|
+
### Offered versus durable progress
|
|
254
|
+
|
|
255
|
+
A native backend accepts several batches before its next barrier, so the owner tracks **offered**
|
|
256
|
+
progress (cursor vectors of accepted batches, in memory, under the lock) separately from the
|
|
257
|
+
backend's **durable** cursor. Every acquisition mints an owner epoch from a process-wide atomic
|
|
258
|
+
counter and stamps it on each batch; a new epoch — including after a worker restart — resets offered
|
|
259
|
+
progress to the durable cursor before opening its iterator, because replaying work that survived
|
|
260
|
+
in a native queue is safe and trusting a dead worker's non-durable position is not. A reported
|
|
261
|
+
durable cursor must equal one offered vector exactly; validating logs independently would let a
|
|
262
|
+
backend assemble a cursor from different batch boundaries and hide work. Accepted-not-durable
|
|
263
|
+
progress is capped (`maxAcceptedBatchesAhead`, 64); at the cap the owner keeps the lock, stops
|
|
264
|
+
reading (`waiting-durable`) and resumes on a backend state-change wake, not on commit wakes.
|
|
265
|
+
|
|
266
|
+
### Authoritative record resolution and the eviction marker
|
|
267
|
+
|
|
268
|
+
The log decides what must be revisited; the primary store decides what is in the index now. A
|
|
269
|
+
present entry yields its current version and projection; a missing, deleted, evicted or
|
|
270
|
+
invalidated entry yields `absent`, which is sound only because every removal now has a durable
|
|
271
|
+
fact: `delete`, `invalidate`, `relocate`, or the local-only `evict` marker that `Table.evict()` and
|
|
272
|
+
`createEvictionBatcher().stageInto()` stage into the same RocksDB transaction as the row removal for
|
|
273
|
+
tables with a registered derived index (`hasDerivedIndexRegistration`). The marker is `LOCAL_ONLY`,
|
|
274
|
+
a no-op in boot replay, filtered from customer history and subscriptions, and rejected by
|
|
275
|
+
replication. `message`, `publish` and structure entries advance progress without becoming
|
|
276
|
+
documents; a `reload` marker (replica base copy) condemns the generation because its rows have no
|
|
277
|
+
per-record entries. A projection that throws a 4xx `ClientError` yields
|
|
278
|
+
`{ kind: 'unindexable', reason: '<class> (<status>)' }` — the backend removes any entry and counts
|
|
279
|
+
it; the message never reaches shared memory or the backend because validation messages quote
|
|
280
|
+
record values. Any other projection or primary-read failure is fail-closed.
|
|
281
|
+
|
|
282
|
+
### Backend contract
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
interface DerivedIndexBackend {
|
|
286
|
+
readonly id: string;
|
|
287
|
+
attach(host: { isOwnerEpoch(epoch: bigint): boolean; getReadiness(): DerivedIndexReadiness }): void;
|
|
288
|
+
getDurableCursor(): DerivedIndexCursor | undefined;
|
|
289
|
+
deliver(batch: DerivedIndexBatch): DERIVED_INDEX_ACCEPTED | DERIVED_INDEX_DEFERRED | DERIVED_INDEX_FAILED;
|
|
290
|
+
flush(reason: 'age' | 'threshold' | 'shutdown'): void | Promise<void>; // barrier request
|
|
291
|
+
shutdown(ownerEpoch: bigint): void | Promise<void>; // quiescence: nothing further applies or publishes for the epoch
|
|
292
|
+
onStateChange(wake: (change?: 'changed' | 'accepted-work-lost' | 'failed') => void): () => void;
|
|
293
|
+
reset?(ownerEpoch: bigint): void | Promise<void>; // destroy state and cursor; first durable action invalidates the cursor
|
|
294
|
+
}
|
|
295
|
+
type DerivedIndexBatch = {
|
|
296
|
+
ownerEpoch: bigint;
|
|
297
|
+
transactions: { logName; timestamp; mutations }[];
|
|
298
|
+
records: DerivedIndexMutation[]; // last-write-wins per (tableId, writeKeyId(recordId)); non-enumerable
|
|
299
|
+
through?: DerivedIndexCursor; // absent on a rebuild scan chunk and while a transaction is still open
|
|
300
|
+
bytes: number; // estimate; non-enumerable
|
|
301
|
+
rebuild?: true;
|
|
302
|
+
};
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
There is one contract and every hook is required. What an ownership handoff has to fence is work
|
|
306
|
+
that survives a method return — a queued apply, a barrier that completes later, a cursor that
|
|
307
|
+
trails delivery — so every backend supplies the epoch fence, the barrier request and the quiescence
|
|
308
|
+
handshake; one that completes inside `deliver()` implements them trivially. `deliver()` is not
|
|
309
|
+
bounded by the runner's turn budget (the runtime cannot bound work it does not perform): the
|
|
310
|
+
expected shape is enqueue, return `accepted`, apply in the backend's own time slices, advance the
|
|
311
|
+
durable cursor at its barrier, return `deferred` when its queue is full. `accepted` means the backend
|
|
312
|
+
owns the batch, not that the cursor may advance. `deferred` holds the batch until a state-change
|
|
313
|
+
wake. `accepted-work-lost` makes the owner rebuild its iterator from the durable cursor; `failed` or
|
|
314
|
+
`DERIVED_INDEX_FAILED` condemns the generation. `reset` is optional — without it `needs-rebuild` is
|
|
315
|
+
terminal — and a backend that implements it owns its crash safety: it must invalidate the cursor
|
|
316
|
+
before anything destructive, because shared readiness is process memory and is no evidence after a
|
|
317
|
+
restart. The runtime also writes a condemnation marker to the root store
|
|
318
|
+
(`Symbol.for('derived-index:<id>:condemned')`) before any reset and clears it at the first durable
|
|
319
|
+
`ready`, so a restart between condemning a cursor and destroying it still rebuilds; a marker that
|
|
320
|
+
cannot be written issues no reset. The cursor's atomic durability mechanism is the backend's
|
|
321
|
+
(Tantivy publishes it with segment state; HNSW writes it after the plane barrier), which is why the
|
|
322
|
+
cursor is backend-owned and validation is Harper's.
|
|
323
|
+
|
|
324
|
+
### Native full-text backend
|
|
325
|
+
|
|
326
|
+
`resources/indexes/fullTextDerivedIndex.ts` adapts the shared runtime to
|
|
327
|
+
`@harperfast/fulltext/native`; it does not implement another replay or ownership protocol. Harper
|
|
328
|
+
turns each resolved mutation into one stable document id from `tableId` and the record id's
|
|
329
|
+
ordered-binary storage-key bytes, and
|
|
330
|
+
passes only the schema-selected string and array fields to the wrapper. Harper does not rescan array
|
|
331
|
+
contents; the wrapper owns value validation, Tantivy schema, exact frame partitioning, its exclusive writer, segment publication, and file
|
|
332
|
+
lifecycle. Harper keeps accepted runtime batches in a 64 MiB bounded queue and submits at most 256
|
|
333
|
+
records or 5 ms of conversion work per turn, so the runtime's 4096-record chunk cannot become one
|
|
334
|
+
long event-loop task. A rebuild chunk without a source-size estimate consumes the adapter's entire
|
|
335
|
+
queue-byte allowance, ensuring that only one unknown-size chunk is retained at a time. Wrapper
|
|
336
|
+
rejections remove the previous document and count it as unindexable; they do not leave stale search
|
|
337
|
+
content. A projector returning null or no string-valued fields deletes the prior document rather
|
|
338
|
+
than indexing an empty replacement.
|
|
339
|
+
|
|
340
|
+
Every runtime flush is a native publication barrier. Full-text activation chooses and benchmarks the
|
|
341
|
+
runtime flush thresholds; the adapter does not reinterpret a flush because the runtime uses durable
|
|
342
|
+
cursor progress to bound replay work and transaction-log retention.
|
|
343
|
+
|
|
344
|
+
The native commit payload contains Harper's exact derived-index cursor. A publish makes the Tantivy
|
|
345
|
+
mutations and that payload visible together; only then does the adapter report durable progress.
|
|
346
|
+
An ordinary apply or publish failure rollback-closes the writer, discards accepted-but-unpublished
|
|
347
|
+
work, and wakes the runtime to replay from the last native payload after exponential backoff to a
|
|
348
|
+
five-second ceiling. It does not condemn a structurally valid generation. During a rebuild, the same
|
|
349
|
+
accepted-work-lost signal aborts that rebuild attempt; the runtime retires the partial generation and
|
|
350
|
+
spends one of its bounded rebuild attempts before rescanning. A mutation-batch protocol
|
|
351
|
+
violation remains permanent because retry cannot change the wrapper contract. Writer open is lazy.
|
|
352
|
+
After a small immediate attempt budget, open errors use the same retry ceiling unless their stable
|
|
353
|
+
code proves the native generation is structurally incompatible or corrupt. Configuration, binding,
|
|
354
|
+
process-state, and unknown codes retry by default: they may require operator action or restart, but do
|
|
355
|
+
not prove the index files should be reset. Persistent writer unavailability emits one warning without native paths
|
|
356
|
+
or record content. Ownership handoff does not finish until drain and close prove quiescence. A
|
|
357
|
+
writer-open failure that lands after shutdown begins discards queued work before finalization so it
|
|
358
|
+
cannot enter another retry drain. Harper
|
|
359
|
+
gives native close its own 35-second bound and bounds the complete handoff at 70 seconds. If that proof fails, shutdown rejects and the runtime keeps its
|
|
360
|
+
runner lock, preventing a second writer. The underlying native operation continues and a later operator
|
|
361
|
+
retry attaches to the same shutdown rather than starting a competing close. A cursor that cannot fit
|
|
362
|
+
the native commit-payload limit is terminal for that backend instance: accepted work is rollback-closed
|
|
363
|
+
and reported lost, then further delivery stays deferred. The adapter does not report a permanent
|
|
364
|
+
backend failure because condemnation and reset cannot shrink the cursor. Automatic reset is refused
|
|
365
|
+
until configuration changes replace the backend instance. This terminal park keeps readiness
|
|
366
|
+
non-terminal; when the derived-index lag policy is enabled, source writes remain rejected after the
|
|
367
|
+
lag limit is crossed until the deployment is changed so the cursor fits and the backend is replaced,
|
|
368
|
+
or the index is disabled. Inspection accepts any payload within Harper's fixed 64 KiB format bound,
|
|
369
|
+
independent of the current publication limit, so lowering that limit never turns an already-valid
|
|
370
|
+
native generation into rebuild work.
|
|
371
|
+
|
|
372
|
+
Inspection is synchronous and writer-free. The first durable-cursor read in each ownership acquisition
|
|
373
|
+
refreshes native state, while later reads in the same acquisition use the cache; every shutdown path
|
|
374
|
+
invalidates it, including an owner that received no batch. A refresh failure never falls back to a
|
|
375
|
+
cached checkpoint because the runtime can publish `ready` before lazy writer-open reconciliation.
|
|
376
|
+
The synchronous backend contract has no retryable acquisition result, so an inspection exception
|
|
377
|
+
deliberately fails closed: the runtime condemns the generation and rebuilds from authoritative
|
|
378
|
+
records rather than trusting an unverified cursor. A transient filesystem error can therefore cost
|
|
379
|
+
a full rescan; reintroducing asynchronous acquisition solely to avoid that trade is out of scope.
|
|
380
|
+
Missing, cursorless, incompatible, or malformed native state has no usable cursor and therefore enters
|
|
381
|
+
the runtime's ordinary local rebuild from records.
|
|
382
|
+
Reset first asks the wrapper to retire the live generation atomically, then reclaims only wrapper-
|
|
383
|
+
validated retired paths. The reset operation is tracked and bounded; shutdown attaches to the same
|
|
384
|
+
operation and keeps the runner lock if it cannot prove settlement. Retired-path reclamation is
|
|
385
|
+
best-effort, serialized per lifecycle, and never extends that reset handoff. The native directory is node-local derived state: restarts reuse it and
|
|
386
|
+
replay after its payload; replicas independently derive it from their own applied transaction log;
|
|
387
|
+
backup and restore need only authoritative records and schema. A new or unusable directory serves no
|
|
388
|
+
full-text queries until rebuild and catch-up publish `ready`.
|
|
389
|
+
|
|
390
|
+
Tantivy files are not an opaque encrypted cache. They contain the document-id term dictionary,
|
|
391
|
+
analyzed term dictionaries, postings, frequencies and optionally positions; `surfaceTerms: true`
|
|
392
|
+
also stores the original projected strings needed for surface-term features. Operators must protect
|
|
393
|
+
the full-text directory with the same filesystem controls as Harper data. Removing source records
|
|
394
|
+
does not erase old segment bytes immediately; normal Tantivy merge/reclamation governs physical
|
|
395
|
+
removal, and destroying an index uses the wrapper's retirement protocol.
|
|
396
|
+
|
|
397
|
+
The binding remains unloaded until a full-text declaration is activated. Before activation can
|
|
398
|
+
construct this backend, Harper must exact-pin the Fulltext package, document the dependency in
|
|
399
|
+
`dependencies.md`, and prove that its native prebuild loads on Linux, macOS, and Windows CI. A
|
|
400
|
+
missing or incompatible binding is an activation error; Harper must not silently omit the declared
|
|
401
|
+
index.
|
|
402
|
+
|
|
403
|
+
### Bounded delivery
|
|
404
|
+
|
|
405
|
+
A drain turn **collects** identities from the iterator — `(tableId, recordId, logVersion)` per
|
|
406
|
+
eligible entry, no primary read — under `maxTransactionsPerTurn`, `maxBytesPerTurn`,
|
|
407
|
+
`maxMillisecondsPerTurn` and `maxChunkRecords` distinct keys, then **resolves** each key once after
|
|
408
|
+
its last collected occurrence, under `maxChunkBytes` and the same wall budget, carrying the
|
|
409
|
+
remainder to the next turn. Resolving after the last occurrence, not on first encounter, is what
|
|
410
|
+
keeps a writer committing between two occurrences of a key from having its later state certified by
|
|
411
|
+
the cursor while the earlier state stayed indexed. An oversized transaction is delivered across
|
|
412
|
+
chunks with `through` withheld until the chunk that contains its `endTxn`; nothing marks such a
|
|
413
|
+
chunk because a backend can do nothing with the distinction, and query-visible atomicity of one
|
|
414
|
+
transaction across chunks is not promised. `bytes` is an estimate from stored record sizes (or the
|
|
415
|
+
log entry size), never a serialization; `maxChunkRecords` is the hard bound. All bounds are settable
|
|
416
|
+
per registration.
|
|
417
|
+
|
|
418
|
+
### Durability cadence
|
|
419
|
+
|
|
420
|
+
`maxAcceptedBatchesAhead` is a ceiling; the runtime, which already tracks accepted-not-durable work,
|
|
421
|
+
is the scheduler. It requests `flush('age')` when the first accepted batch since the last request is
|
|
422
|
+
`maxFlushAgeMilliseconds` old (1 s), `flush('threshold')` at `flushAfterMutations` (4096) or
|
|
423
|
+
`flushAfterBytes` (8 MiB), and `flush('shutdown')` at release. The backend runs the barrier
|
|
424
|
+
asynchronously, coalesces requests that arrive mid-barrier, and publishes `through` atomically with
|
|
425
|
+
what the barrier made durable. Reaching the end of the log requests no extra barrier — arrivals
|
|
426
|
+
spaced just beyond drain completion would otherwise pay one per write; the age timer is idle
|
|
427
|
+
completion.
|
|
428
|
+
|
|
429
|
+
### Rebuild
|
|
430
|
+
|
|
431
|
+
When the backend has `reset` and the runtime was built with `scanRecords`, `needs-rebuild` is a
|
|
432
|
+
phase, not an end state: publish `rebuilding`; `shutdown(previousEpoch)`; mint a new epoch and
|
|
433
|
+
republish; `reset(newEpoch)` (afterwards `getDurableCursor()` must be `undefined`); capture the
|
|
434
|
+
**committed tail** of every log; scan every registered table (tombstones and symbol keys skipped),
|
|
435
|
+
project, deliver bounded chunks with `through` absent; deliver a final chunk with `through` = tail;
|
|
436
|
+
install the tail as offered progress and replay through the ordinary drain; publish `ready` at the
|
|
437
|
+
first durable advance past the tail or the first idle pass with durable == offered.
|
|
438
|
+
|
|
439
|
+
The tail is safe because a committed read is a contiguous physical prefix: rocksdb-js keeps the
|
|
440
|
+
physically-written-but-uncommitted start offsets in a sorted set and `commitFinished()` advances
|
|
441
|
+
`lastCommittedPosition` to the earliest of them (`TransactionLogStore::commitFinished`,
|
|
442
|
+
`uncommittedTransactionPositions.front()`), so a transaction that wrote at offset 200 and committed
|
|
443
|
+
before one pending at 100 stays invisible until 100 commits. Everything committed before the
|
|
444
|
+
capture is in the scan; everything after it is replayed; a reload marker is therefore met exactly
|
|
445
|
+
once, with no capture-time bookkeeping. A log that cannot be read to its tail fails the attempt
|
|
446
|
+
closed — corruption inside the committed prefix cannot be replayed from any anchor. Attempts retry
|
|
447
|
+
with backoff (`rebuildBackoffMilliseconds` 1 s doubling to 5 min) up to `maxRebuildAttempts` (8),
|
|
448
|
+
then publish `unavailable` and release; the attempt count travels in shared memory so a peer
|
|
449
|
+
honours an exhausted budget. `requestRebuild()` from any worker sets a request word the owner
|
|
450
|
+
consumes at its next wake.
|
|
451
|
+
|
|
452
|
+
### Native HNSW query coverage
|
|
453
|
+
|
|
454
|
+
Generation readiness and read freshness are separate. A `ready` native index can still be applying
|
|
455
|
+
committed mutations. Native vector sort/threshold conditions accept `maxIndexLagMilliseconds`:
|
|
456
|
+
**3000 ms by default**, `0` for strict coverage, or an explicit finite nonnegative number. This default
|
|
457
|
+
allows three ordinary 1000 ms flush ages; it does not change the separate writer backpressure budget.
|
|
458
|
+
Synchronous/non-native indexes ignore the native coverage options. For example, an HTTP QUERY body can contain:
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
{
|
|
462
|
+
"sort": {
|
|
463
|
+
"attribute": "vector",
|
|
464
|
+
"target": [1, 0, 0, 0],
|
|
465
|
+
"distance": "cosine",
|
|
466
|
+
"maxIndexLagMilliseconds": 0
|
|
467
|
+
},
|
|
468
|
+
"limit": 10
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
For read-after-write, add `waitForIndexMilliseconds: 10000` to that sort (or vector condition).
|
|
473
|
+
Omitted or `0` preserves immediate admission; a positive finite number, capped at **30,000 ms**,
|
|
474
|
+
opts into a bounded wait for coverage of writes committed before the native search begins on first iteration.
|
|
475
|
+
The wait takes precedence over lag tolerance: a recent but stale proof cannot satisfy it. An already
|
|
476
|
+
physically current index proceeds immediately. Otherwise the query captures one monotonic target and
|
|
477
|
+
waits for the owner's certified time to reach it; later writes never reset the target. Each consumed
|
|
478
|
+
waiting branch has its own budget; sequential OR or concatenated branches can take longer in total.
|
|
479
|
+
A timeout throws retryable `DERIVED_INDEX_LAGGING`. On an already-started HTTP stream this is an error
|
|
480
|
+
record, not a new HTTP status. Request cancellation and iterator closure also end pending waits.
|
|
481
|
+
|
|
482
|
+
Waiting preserves the normal 1000 ms flush-age schedule: a small write followed by a search commonly
|
|
483
|
+
waits about a second plus barrier time. Shorter deadlines are valid but may expire. Replay must reach
|
|
484
|
+
an end-of-log pass with readable committed prefixes to publish a new capture; sustained overload or
|
|
485
|
+
unfinished transactions can prevent certification and cause timeouts even for unaffected tables.
|
|
486
|
+
The wait does not promise exact ANN recall, a historical graph snapshot, or visibility of writes that
|
|
487
|
+
have not committed locally.
|
|
488
|
+
|
|
489
|
+
When a vector condition also provides the sort order, each explicit coverage option takes precedence;
|
|
490
|
+
missing options inherit the sort's values. The query planner preserves both when combining them.
|
|
491
|
+
|
|
492
|
+
An ordinary non-waiting native query certifies coverage **at admission**. `Harper-Index-Coverage` is
|
|
493
|
+
`current; lag=0; tolerance=<requested-ms>` or `bounded; lag=<upper-bound-ms>; tolerance=<requested-ms>`.
|
|
494
|
+
Bounded coverage can omit recent committed mutations; it is not a claim of known incompleteness or an
|
|
495
|
+
ANN recall guarantee. The header is exposed to CORS clients and does not certify a later traversal.
|
|
496
|
+
Multiple non-waiting searches append one entry each.
|
|
497
|
+
|
|
498
|
+
Waiting queries retain the synchronous instance iterable API: custom resources can directly use
|
|
499
|
+
`super.search(query).map(...)` or concatenate results. Validation and unavailable/rebuilding checks
|
|
500
|
+
remain synchronous; the adapter opens a start gate only on consumption, so unused searches start no
|
|
501
|
+
wait or traversal. A zero-size page skips native work. The iterable library can pull one extra source
|
|
502
|
+
row at a page boundary; an exact boundary between branches can therefore start the next branch.
|
|
503
|
+
|
|
504
|
+
Waiting queries publish no HTTP coverage header: a header sent before consumption cannot certify the
|
|
505
|
+
pending work. The caller must consume results and check streamed errors. JSON array streams may include
|
|
506
|
+
an error element such as `{ "error": "DerivedIndexLagError: ..." }`; error serialization may instead
|
|
507
|
+
provide a separate `message` field. SSE/NDJSON use their existing terminal error records. HTTP 200 alone
|
|
508
|
+
is not evidence of successful completion. Prefix rows can precede a later branch error. Count pages
|
|
509
|
+
materialize before headers and can still return an error status. First-item HTTP status deferral is a
|
|
510
|
+
separate follow-up (#2670), not a guarantee here. Direct custom-index arrays retain `indexCoverage`;
|
|
511
|
+
ordinary native promises expose it before awaiting, while waiting promises expose it on resolved arrays
|
|
512
|
+
except for the zero-size fast path, which skips certification and carries no proof.
|
|
513
|
+
|
|
514
|
+
Waiting consumes the ordinary transaction timeout without special monitor renewal. An expired read
|
|
515
|
+
snapshot fails with `ReadSnapshotExpiredError`; timed-out staged writes retain their existing 422
|
|
516
|
+
failure and rollback. The adapter uses the captured snapshot for materialization and guards predicate
|
|
517
|
+
reads, so it never recreates a snapshot after waiting. Dropping an unconsumed iterable starts no work;
|
|
518
|
+
once consuming, close its iterator or abort the request to cancel. Lag rejection never invalidates a
|
|
519
|
+
healthy plane. The default/strict no-wait lag rejection remains HTTP 503.
|
|
520
|
+
File-primary node-to-record mappings are read from current storage, just like the native graph itself:
|
|
521
|
+
an older record snapshot must not hide mappings published after a record already visible in that snapshot.
|
|
522
|
+
Record filtering and materialization retain the request's snapshot; the native graph is not an MVCC index.
|
|
523
|
+
|
|
524
|
+
The runner captures the RocksDB process-wide transaction clock (`getMonotonicTimestamp()`) before listing
|
|
525
|
+
physical logs and polling their committed
|
|
526
|
+
prefixes. It synchronously adds discovered logs to the audit store's worker-local map. A capture is
|
|
527
|
+
usable only when each stats snapshot's committed position equals its written head: an earlier unfinished
|
|
528
|
+
transaction can hide later committed transactions behind the readable prefix. The native statistics
|
|
529
|
+
counter alone is insufficient here (it can remain nonzero at an equal head). Positions include both file
|
|
530
|
+
sequence and byte offset; cursor timestamps and origin clocks are never ordered or subtracted to prove
|
|
531
|
+
freshness.
|
|
532
|
+
|
|
533
|
+
After a poll reaches the end with no undelivered records, the capture is associated with its offered
|
|
534
|
+
cursor. Reconciliation publishes it only when that cursor, or a later entry in the ordered offered queue,
|
|
535
|
+
is durable. Unrelated-only progress may publish without a new barrier only if **all** non-durable
|
|
536
|
+
registered mutations, including unanchored chunks, are absent. Thus a queued relevant mutation cannot
|
|
537
|
+
be skipped by a later unrelated commit. Publication is owner/epoch fenced. The physical vector is an
|
|
538
|
+
optional `coverage` field in the existing durable cursor value, preserved by later cursor writes and
|
|
539
|
+
removed with the cursor before reset. No native file format changes.
|
|
540
|
+
|
|
541
|
+
Persisted coverage rewrites are limited to the flush cadence, including unrelated-table traffic. The
|
|
542
|
+
shared time can refresh sooner once its prefix is durable. A coverage-only write failure logs and skips
|
|
543
|
+
publication instead of rebuilding the healthy graph. Strict queries may wait for the next persisted proof.
|
|
544
|
+
|
|
545
|
+
Restart identity relies on the existing storage durability ordering: these index column families disable
|
|
546
|
+
WAL, and [RocksDB's database-flush callback](https://github.com/HarperFast/rocksdb-js/blob/v2.9.1/src/binding/transaction_log/transaction_log_store.cpp#L1080-L1111)
|
|
547
|
+
flushes transaction-log files before their index-store flush can become durable. Under successful storage
|
|
548
|
+
flushes, surviving coverage cannot refer to a lost, reusable log tail. Recovery also protects the flushed
|
|
549
|
+
prefix. [Age-based rotation runs on a write](https://github.com/HarperFast/rocksdb-js/blob/v2.9.1/src/binding/transaction_log/transaction_log_store.cpp#L947-L963),
|
|
550
|
+
so an ordinary idle log does not advance its head merely because time passed. These are dependency
|
|
551
|
+
contracts, not a guarantee against externally replacing log files or failed storage durability.
|
|
552
|
+
|
|
553
|
+
Bun gives each worker a different `process.hrtime.bigint()` origin, so it cannot certify cross-worker
|
|
554
|
+
coverage. The transaction clock is shared across workers; its milliseconds are encoded as integer
|
|
555
|
+
nanoseconds without multiplying the full epoch-sized floating-point value.
|
|
556
|
+
|
|
557
|
+
The monotonic time lives only in the process-wide shared readiness buffer and is cleared on non-ready
|
|
558
|
+
health transitions. An owner refreshes idle coverage at its flush cadence without extending its idle
|
|
559
|
+
release deadline. A query within the certified age bound reads only shared memory and the monotonic
|
|
560
|
+
clock; strict or older queries compare the persisted vector with current physical positions. This also
|
|
561
|
+
certifies an unchanged index after owner release or process restart, when no usable time proof remains.
|
|
562
|
+
Concurrent waiters on one worker/index share a single 25 ms poll timer, removed when all resolve,
|
|
563
|
+
time out, or abort. A first waiter nudges its local runner without changing writer-lag accounting or
|
|
564
|
+
retrying a deferred batch; an active peer owner already refreshes at flush cadence. No cross-worker
|
|
565
|
+
notification or per-query persisted coverage write is needed. Closing a registration rejects its waiters.
|
|
566
|
+
A completed nonempty drain also attaches its capture to the accepted boundary; ongoing writes need
|
|
567
|
+
not leave an empty turn between batches. The capture remains fenced behind every indexed mutation
|
|
568
|
+
accepted through that boundary.
|
|
569
|
+
The strict/ownerless path costs a cursor read and stats per physical log. If a database-wide backlog
|
|
570
|
+
prevents the owner from inspecting unrelated writes, coverage can conservatively become unprovable
|
|
571
|
+
for an otherwise unaffected index; queries do not scan logs to classify that backlog.
|
|
572
|
+
|
|
573
|
+
### Handoff fencing
|
|
574
|
+
|
|
575
|
+
Release drops ownership, calls `flush('shutdown')` then `shutdown(epoch)`, and unlocks only when
|
|
576
|
+
that settles; a rejected `shutdown` **keeps the lock** and publishes `unavailable`, because a backend
|
|
577
|
+
that cannot prove its queue quiescent must not hand the index to another owner. `stop()` and the
|
|
578
|
+
unregister function return one cached promise that resolves after every backend settled and rejects
|
|
579
|
+
if any shutdown failed, so a caller cannot close storage while a backend is still draining into it.
|
|
580
|
+
`isOwnerEpoch(epoch)` is an `Atomics.load` of the shared counter; a backend checks it before each
|
|
581
|
+
apply, after each await and in barrier completions, and drops work for a superseded epoch. The
|
|
582
|
+
runner tracks a generation that changes on every acquisition, discard, reset and release and
|
|
583
|
+
ignores continuations from an earlier one.
|
|
584
|
+
|
|
585
|
+
### Shared readiness
|
|
586
|
+
|
|
587
|
+
`indexStore.isIndexing` is per worker, so the owner publishes into one `getUserSharedBuffer`
|
|
588
|
+
allocation per backend (`derived-index:<id>:readiness`, `READINESS_BYTES`): `Int32` words for state
|
|
589
|
+
(`unknown | ready | rebuilding | needs-rebuild | unavailable`), a `DerivedIndexReadinessReason` code,
|
|
590
|
+
attempt count, rebuild request and lag-exceeded flag, then the `BigInt64` owner-epoch counter. Each
|
|
591
|
+
word is read with a plain `Atomics.load`; nothing needs two of them atomically, so there is no
|
|
592
|
+
sequence lock — the owner stores reason and attempts before state. The shared reason is a code,
|
|
593
|
+
never a message. `readDerivedIndexReadiness(logStore, id)` reads it on any worker without a runtime;
|
|
594
|
+
a query path uses it to choose between a 503 and an answer. `Atomics` over rocksdb-js's external
|
|
595
|
+
`ArrayBuffer` wrappers is the same dependency primary-key allocation (`Table.ts`), blob holds
|
|
596
|
+
(`blob.ts`) and HNSW node ids already carry; wakes use the binding's `notify()`, never
|
|
597
|
+
`Atomics.wait`. A successor publishes `ready` on acquisition one `setImmediate` before its lazy
|
|
598
|
+
`exactStart` validation can condemn the inherited cursor; readers see the previous, self-consistent
|
|
599
|
+
generation for that turn.
|
|
600
|
+
|
|
601
|
+
### Lag policy
|
|
602
|
+
|
|
603
|
+
Opt-in per registration (`maxLagMilliseconds`; 0 = none; raised to two flush ages since catch-up is
|
|
604
|
+
only proven at a barrier). The owner takes the longest of: cursor distance behind what it has read,
|
|
605
|
+
time parked on backpressure or the ceiling, time since the oldest commit it may not have read
|
|
606
|
+
(bounded by how far its newest read trails the clock), and the age of the oldest accepted work not
|
|
607
|
+
yet durable. Past the budget it sets the shared flag; every worker's `derivedIndexWriteRejection`
|
|
608
|
+
(`resources/derivedIndexRegistry.ts`, one `WeakMap` miss for tables without a derived index) then
|
|
609
|
+
fails local user writes to the index's tables with `DerivedIndexLagError` — 503,
|
|
610
|
+
`DERIVED_INDEX_LAGGING`, `retryable: true` — at the staging layer (`_writeUpdate`, `_writeDelete`,
|
|
611
|
+
`_writeInvalidate`, `_writeRelocate`), never canonical-source applies (`transaction.sourceApply`),
|
|
612
|
+
crash-recovery replay or replication notifications, since a rejected canonical write would advance
|
|
613
|
+
the source cursor past a write that never landed. The flag is owned by the lock holder: it clears
|
|
614
|
+
with hysteresis once the owner has proven catch-up (a durable advance and the end of the log both
|
|
615
|
+
reached since acquiring, lag below half the budget), and on every transition out of "behind and
|
|
616
|
+
still reading" — entering a rebuild (no cursor to guard; readers act on `rebuilding`), `unavailable`,
|
|
617
|
+
a `needs-rebuild` the runtime cannot leave, a condemnation marker it could not write (its retry
|
|
618
|
+
needs a commit wake, and commits were what was being shed), and a held lock. An ordinary handoff
|
|
619
|
+
preserves it. The policy sheds writes; it does not pin retention — rocksdb-js has no protected
|
|
620
|
+
position registration — so a budget belongs well inside the effective retention window, and it must
|
|
621
|
+
be enabled only once every worker runs a runtime with the admission check.
|
|
622
|
+
|
|
623
|
+
### Failure flow
|
|
624
|
+
|
|
625
|
+
```mermaid
|
|
626
|
+
flowchart TD
|
|
627
|
+
A[load backend cursor] --> B{all saved logs and boundaries exact?}
|
|
628
|
+
B -->|no| X{backend has reset and runtime has scanRecords?}
|
|
629
|
+
B -->|yes| C[open aggregate iterator after anchors]
|
|
630
|
+
C --> D[bounded drain: collect, resolve, deliver]
|
|
631
|
+
D -->|accepted| E[record offered cursor vector]
|
|
632
|
+
D -->|deferred| F[park until backend wake]
|
|
633
|
+
D -->|failed or threw| X
|
|
634
|
+
E -->|backend barrier| G[durable cursor equals one offered vector]
|
|
635
|
+
E -->|accepted batches at cap| W[waiting-durable]
|
|
636
|
+
G --> T[publish ready]
|
|
637
|
+
X -->|no| Z[terminal: release lock, index unavailable]
|
|
638
|
+
X -->|yes| Y[publish rebuilding, shutdown old epoch, reset, scan, tail, replay]
|
|
639
|
+
Y -->|ready after final barrier| T
|
|
640
|
+
Y -->|failure| K{attempts below cap?}
|
|
641
|
+
K -->|yes, after backoff| Y
|
|
642
|
+
K -->|no| U[publish unavailable, release lock]
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
## Native HNSW plane: a file-primary mmap graph on the derived-index runtime (`resources/indexes/HierarchicalNavigableSmallWorld.ts`, `resources/indexes/hnswDerivedIndex.ts`, `resources/indexes/hnswPlaneBinding.ts`)
|
|
646
|
+
|
|
647
|
+
For a new locally declared HNSW index, Harper uses the native plane by default when the table's
|
|
648
|
+
primary descriptor already stores `audit: true` (or the same declaration explicitly enables it),
|
|
649
|
+
the native binding loads, RocksDB is in use, and the index has compatible geometry. An explicit
|
|
650
|
+
`nativePlane: false` selects the JS graph. The native plane replaces the RocksDB graph with a
|
|
651
|
+
memory-mapped fixed-slot file owned by `@harperfast/hnsw` (Rust, napi-rs, exact-pinned optional
|
|
652
|
+
dependency; crate at HarperFast/hnsw). The file **is the index**: graph nodes, adjacency, the entry
|
|
653
|
+
point, the id allocator, the freelist and each node's primary key exist only there. RocksDB keeps
|
|
654
|
+
the primary records, the `pk → nodeId` mapping (which node a key owns, for replacement and replay)
|
|
655
|
+
and the one durable replay cursor.
|
|
656
|
+
|
|
657
|
+
The decision belongs to the durable index-creation boundary, not `openIndex()` or the HNSW
|
|
658
|
+
constructor: those are also catalog-reload paths. An existing descriptor is therefore authoritative
|
|
659
|
+
when a later declaration omits `nativePlane`; legacy descriptors with no field stay on the JS graph,
|
|
660
|
+
and legacy string values retain their historical truthiness while an exactly matching declaration
|
|
661
|
+
keeps the stored spelling; a numerically equivalent value produced by the GraphQL numeric-literal
|
|
662
|
+
coercion does the same for a pre-upgrade numeric spelling. Numeric HNSW options are likewise
|
|
663
|
+
normalized and validated only at the declaration boundary: an exact redeclaration retains persisted
|
|
664
|
+
legacy values and their previous runtime coercion, while a different canonical declaration triggers
|
|
665
|
+
a rebuild when that interpretation changes (notably zero-valued `optimizeRouting` strings). A table
|
|
666
|
+
whose primary descriptor already stores `audit: true` qualifies for the default even when that value
|
|
667
|
+
originally came from the global audit setting. In
|
|
668
|
+
contrast, a new table and its omitted-mode index in the same declaration do not qualify unless that
|
|
669
|
+
declaration explicitly enables audit, because audit was not durable at the index-creation boundary.
|
|
670
|
+
A replicated new attribute uses native mode only when the receiving node is independently audited
|
|
671
|
+
and eligible, because the plane is node-local derived state. An ineligible receiver persists its
|
|
672
|
+
fallback as `nativePlane: false` in the local catalog, so installing the binding or changing storage
|
|
673
|
+
later does not switch that index automatically; redeclare it locally with `nativePlane: true` after
|
|
674
|
+
the node becomes eligible. Set
|
|
675
|
+
`HNSW_NO_NATIVE_DEFAULT=1` to keep newly omitted declarations on JS during rollout; it does not
|
|
676
|
+
disable an explicit or already persisted native index. Set it before creating indexes when a
|
|
677
|
+
rollback must remain cheap: an older release sees an omitted declaration against the persisted
|
|
678
|
+
`nativePlane: true` decision as a mode change and rebuilds that index back to JS. In a cluster, keep
|
|
679
|
+
the switch enabled on every node until all nodes run a release with the replicated-attribute fallback.
|
|
680
|
+
|
|
681
|
+
The default accepts the native implementation's existing operational contract: maintenance is
|
|
682
|
+
post-commit; writes receive retryable 503 responses after derived-index lag exceeds 30 seconds;
|
|
683
|
+
per-query distance overrides are rejected; the default capacity is 16M nodes; and adding the index
|
|
684
|
+
to a populated table keeps searches unavailable for the rebuild, which can take hours at large
|
|
685
|
+
sizes. `nativePlane: false` is the durable per-index opt-out.
|
|
686
|
+
|
|
687
|
+
Why the whole search loop is native and not just the distance kernel: at 5M nodes / ef 512, ~85% of
|
|
688
|
+
a warm JS visit is object bookkeeping (candidate heap, visited `Set`, property access, GC), the int8
|
|
689
|
+
cosine is 10%, and a warm RocksDB `Get` per visit (~1–2 µs) is 20–40× the SIMD distance it feeds. A
|
|
690
|
+
native loop over direct-addressed slots (`base + id × slot_size`, ~100–200 ns) with one NAPI crossing
|
|
691
|
+
per query is the only shape that reaches the ceiling; measured 7.2 ms → 0.75 ms p50 at 1M × 768-d,
|
|
692
|
+
recall@10 0.997 → 0.999.
|
|
693
|
+
|
|
694
|
+
### File format
|
|
695
|
+
|
|
696
|
+
One file per index, `<index store path>/<store name>.hnsw`, created sparse at `nativePlaneMaxNodes`
|
|
697
|
+
slots (16M default; a structural, create-time header field — exhausting it makes the index
|
|
698
|
+
unavailable until the value is raised and the index rebuilt). Header page: magic + format version
|
|
699
|
+
(mismatch → rebuild, by contract), dims, quantization mode, `slot_size`/`layer0_cap`/`upper_cap`
|
|
700
|
+
(`layer0_cap` from `nativePlaneLayer0Cap` at creation; `upper_cap` fixed at 64 by the crate), entry point, atomic `id_high_water`, tag-guarded
|
|
701
|
+
freelist head, a transaction watermark advanced only after an `msync` barrier, and a clean-shutdown
|
|
702
|
+
flag. Layer-0 slot: seqlock word, flags + level, `scale`/`invMag`, degree, int8 vector padded to a
|
|
703
|
+
4-byte boundary, `u32` neighbour ids, then the record's msgpack-encoded primary key (format v8;
|
|
704
|
+
`nativePlaneKeyCap` inline bytes, default 40; a longer key spills to an overflow arena after the
|
|
705
|
+
upper region, reserved at max(128, 4 × keyCap) bytes per node — a table whose keys are mostly
|
|
706
|
+
longer than 40 encoded bytes should raise `nativePlaneKeyCap` rather than live in the arena) — at the default degree cap of 64, 1,088 B at 768-d and 448 B at 128-d, the key fitting the cache-line padding.
|
|
707
|
+
Upper layers (~6% of nodes) live in a fixed-entry region in the same file, per-entry seqlocked.
|
|
708
|
+
Per-edge cached distances are dropped: recomputing costs ~50 ns natively, storing costs 8 B and
|
|
709
|
+
~40% of a node. Searches and predicate batches return each hit's key with it, so no lookup by node
|
|
710
|
+
id remains on the query path.
|
|
711
|
+
|
|
712
|
+
Degree cap is the per-index `nativePlaneLayer0Cap`, **default 64** (supersedes the fixed 128 of
|
|
713
|
+
2026-08-31, re-measured in [hnsw#14](https://github.com/HarperFast/hnsw/pull/14)): at 128-d and
|
|
714
|
+
768-d int8, cap 64 holds recall@10 within ~0.5 pt of cap 128 at every ef ≥ 128 at 1M and 4M, and
|
|
715
|
+
within ~0.3 pt at 768-d, at the same resident latency — while cutting the 128-d slot 704 → 448 B and
|
|
716
|
+
the 768-d slot 1,344 → 1,088 B, which keeps a 4M-node plane resident under a 2 GB limit that makes
|
|
717
|
+
the cap-128 plane thrash. A live 7.7M-node plane carries a mean layer-0 degree of 29
|
|
718
|
+
([hnsw#7](https://github.com/HarperFast/hnsw/issues/7)), so the reserved slot was mostly padding.
|
|
719
|
+
Cap 32 halves the slot again but trails cap 128 by 1.3–2.2 pts below ef 1024 at 4M and by 1.7 pts
|
|
720
|
+
at 1M for 768-d vectors, so it is a declaration for narrow vectors on a plane that outgrows RAM,
|
|
721
|
+
not a default. The cap is a create-time header field: `getPlane` compares it with the index's
|
|
722
|
+
value on attach and invalidates a plane that disagrees rather than reusing or truncating it, so
|
|
723
|
+
revising it is a rebuild, not a format change. A file-primary index builds no JS graph, so this is
|
|
724
|
+
the only layer-0 maximum it has; the JS graph's own cap in `addConnection` governs
|
|
725
|
+
non-`nativePlane` indexes only. A binary-code v2 slot reopens the question.
|
|
726
|
+
|
|
727
|
+
Upgrading a plane built at 128 costs one rebuild per node, the first time a process opens it under
|
|
728
|
+
the new default; no GA release line carries plane files, so this reaches 5.3 pre-releases only.
|
|
729
|
+
Declaring `nativePlaneLayer0Cap: 128` does not avoid that rebuild — adding the property changes the
|
|
730
|
+
attribute's canonical structural options, which reindexes the attribute by itself
|
|
731
|
+
(`indexOptionsStructurallyChanged` in `resources/databases.ts`); it preserves the geometry only once
|
|
732
|
+
it is already the persisted declaration. The default is not written into a descriptor that omits
|
|
733
|
+
the option, so nodes upgrade independently: a mixed-version cluster has each node rebuild its own
|
|
734
|
+
file as it reaches the new code, and no node invalidates another's.
|
|
735
|
+
|
|
736
|
+
### Concurrency
|
|
737
|
+
|
|
738
|
+
Per-slot lock word: bit 31 locked, low bits the owner's pid; unlocked values are generations that
|
|
739
|
+
readers validate seqlock-style. A lock unchanged for 20 ms whose owner pid is dead is taken over and
|
|
740
|
+
the slot sanitized (marked invalid — a dead writer's payload is half-written; invisible until
|
|
741
|
+
rewritten, never spliced-but-valid). Elapsed time alone never robs a live writer. There is no
|
|
742
|
+
cross-slot atomicity: an insert writes its slot plus ~M neighbours' back-edges independently, and a
|
|
743
|
+
traversal may see a half-linked state — a missing edge or a just-deleted neighbour is skipped. That
|
|
744
|
+
relaxed adherence is safe _here_ because the read path loads the record and rescores exactly, which
|
|
745
|
+
rejects a wrong candidate; it is not a general storage pattern. Fields a reader acts on are read
|
|
746
|
+
through aligned `read_volatile`; the stored vector is an ordinary load so the int8 kernel keeps
|
|
747
|
+
autovectorizing, and a torn vector only perturbs a distance the generation check discards.
|
|
748
|
+
|
|
749
|
+
### Durability
|
|
750
|
+
|
|
751
|
+
`msync` on a cadence, not per commit; the header watermark advances after a completed barrier. The
|
|
752
|
+
graph therefore has bounded-lag durability with deterministic catch-up, while the source of truth
|
|
753
|
+
(records, mappings, cursor) stays transactional. Backup treats the file as node-local derived state:
|
|
754
|
+
include it after a barrier, or rebuild on restore. A file whose format or checksum does not validate
|
|
755
|
+
is rebuilt from records. macOS `msync` is a weaker barrier than Linux (an `F_FULLFSYNC` pass is a
|
|
756
|
+
known follow-up); Windows is supported through the prebuild; performance is a Linux target.
|
|
757
|
+
|
|
758
|
+
### Search
|
|
759
|
+
|
|
760
|
+
One crossing per query: `plane.search(query, k, ef, filter?)` runs on the module's thread pool with
|
|
761
|
+
an epoch-stamped visited array and a fixed-capacity heap, asymmetric int8 distance with SIMD
|
|
762
|
+
(AVX2/VNNI, NEON) and a scalar fallback. Filtering has two paths: a bitset over node ids for
|
|
763
|
+
allow-lists and companion-condition candidate sets (zero callbacks), and a pipelined
|
|
764
|
+
`ThreadsafeFunction` batch path for arbitrary JS predicates that keeps expanding in distance order
|
|
765
|
+
while verdicts are in flight, bounded by the same `filterExpansion` visit budget as the JS path.
|
|
766
|
+
Traversal never blocks on the event loop. A plane-backed `customIndex.search()` returns a
|
|
767
|
+
promise-backed, async-only iterable (`resources/search.ts` wraps it); a synchronous consumer throws.
|
|
768
|
+
Auto-ef reads the node count from the plane's `id_high_water` (freed ids are reused, so it stays
|
|
769
|
+
close to the live count; deletes leave it generous until a rebuild, as with the RocksDB graph). Every vector reaching the plane — a
|
|
770
|
+
committed projection or a query target — passes one invariant (`assertPlaneVector`): array-like of
|
|
771
|
+
positive length, every component a finite f32, a magnitude representable in f32, and the plane's
|
|
772
|
+
`dims` once known. What fails it is the client's 400, never a plane failure; a query the plane
|
|
773
|
+
cannot accept must not be read as corruption and cost a rebuild.
|
|
774
|
+
|
|
775
|
+
### Delivery: a backend on the shared runtime
|
|
776
|
+
|
|
777
|
+
Maintenance is off the record transaction. The commit path (`prepareCommitted`) only validates a
|
|
778
|
+
changed projection; the derived-index runtime (§ above) reads the committed log and delivers batches
|
|
779
|
+
to `HnswDerivedIndexBackend`:
|
|
780
|
+
|
|
781
|
+
- `deliver()` enqueues and returns `accepted` (or `deferred` at 64 MiB queued); an applier drains
|
|
782
|
+
in 5 ms `setImmediate` slices. Each `records` entry (last-write-wins per key) becomes one
|
|
783
|
+
`applyDerivedValue(pk, vector | undefined, version)`: the stored mapping's signature short-circuits
|
|
784
|
+
an unchanged vector, an older observation is discarded, otherwise the old node is removed and the
|
|
785
|
+
new vector inserted — with the primary key in its slot, so it is searchable at once — and the
|
|
786
|
+
`pk -> node` mapping written **pending**. The store holds no `node -> pk` entries: hits and
|
|
787
|
+
predicate candidates carry their keys out of the plane, and a query deduplicates by key for the
|
|
788
|
+
crash case where a replayed record's earlier node survives without a published mapping.
|
|
789
|
+
- `flush()` runs at the next completed batch that carries `through`, or at the drain when none does:
|
|
790
|
+
`plane.flushAsync()`, then pending mappings are published, then that batch's `through` vector is
|
|
791
|
+
written as the cursor under `Symbol.for('derived-index-cursor')`. Waiting for the drain instead
|
|
792
|
+
would leave a catch-up — which never empties the queue — with no barrier at all, so the runtime's
|
|
793
|
+
`flushAfterMutations` / `flushAfterBytes` / `maxFlushAgeMilliseconds` cadence would be inert for
|
|
794
|
+
its whole duration. A rebuild scan chunk carries no `through` and so never interrupts a slice;
|
|
795
|
+
its barrier is the drain one, which the runtime's per-chunk `setImmediate` keeps reaching. An
|
|
796
|
+
interrupting barrier then idles for three times its own duration before another may interrupt:
|
|
797
|
+
application is paused for a barrier, and the runtime re-requests on a 1 s age timer, so on a plane
|
|
798
|
+
whose `flushAsync()` costs seconds — measured at 2–6.5 s on the Windows CI runner — honouring every
|
|
799
|
+
request would spend the whole catch-up inside barriers. The drain barrier is never delayed.
|
|
800
|
+
Application pauses while a barrier is in flight so the barrier publishes exactly the mappings it
|
|
801
|
+
covers. That order is the crash contract: a crash before the barrier leaves pending mappings that
|
|
802
|
+
replay re-derives; after it, a cursor that replays idempotently; never a published mapping to a
|
|
803
|
+
node the file did not durably get, and never a cursor over uncovered state.
|
|
804
|
+
- `reset(epoch)` removes the cursor first, then the file (invalidated in-band and via a `.stale`
|
|
805
|
+
sidecar so no peer adopts a stale inode or an undeletable Windows file), then the mappings.
|
|
806
|
+
- A vector the plane cannot hold at apply time (a dimensionality mismatch only the plane-holding
|
|
807
|
+
worker can see) is skipped and counted, never a rebuild.
|
|
808
|
+
|
|
809
|
+
Readiness is the runtime's shared record on every worker, not `indexStore.isIndexing`: a search on
|
|
810
|
+
a non-`ready` index is a 503 (`unavailable` after the rebuild budget); a `ready` index with no file
|
|
811
|
+
and no surviving node mapping answers no results; one whose file is gone while mappings survive
|
|
812
|
+
asks its owner for a rebuild. A search failure detaches this process from the plane and requests a
|
|
813
|
+
rebuild — only the owner's `reset` destroys state, so a failure observed after a peer has already
|
|
814
|
+
replaced the file cannot take out the replacement. Writer backpressure is the runtime's lag policy
|
|
815
|
+
(`maxLagMilliseconds`, 30 s default on a `nativePlane` attribute), required because accepting
|
|
816
|
+
unique-key load above native insert throughput and then rebuilding at that same throughput cannot
|
|
817
|
+
converge.
|
|
818
|
+
|
|
819
|
+
### What native-plane mode requires, and what it does not promise
|
|
820
|
+
|
|
821
|
+
| requirement | enforcement |
|
|
822
|
+
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
823
|
+
| For the automatic default, a primary descriptor with `audit: true` or an explicit `audit: true` in the same local declaration — the transaction log is the recovery source, and the default must not widen the audit-readable surface by inheriting the global setting. On an existing table whose durable audit field is absent, explicit native mode may pin the effective audit value; a stored `false` wins. A new table still requires explicit audit. | On an existing table, `table()` writes an audit enable before the native index row. A new table publishes its primary row last as the catalog-completeness marker, already carrying explicit audit. Disabling audit writes every non-primary descriptor first, whether native mode is opted out or the index is removed, then writes the primary row last. Catalog reload propagates durable audit to an already-loaded class, and `attachDerivedIndexes()` checks the runtime flag; enabling logs warns once that the audit API retains full record history for the retention window. |
|
|
824
|
+
| RocksDB; `M=16`, `efConstruction=200`, `mL=1/ln(16)`, `optimizeRouting=0.5`, int8 cosine (the package's standalone `insert` fixes this geometry) | `ClientError` at index construction — never a silent rebuild under native defaults |
|
|
825
|
+
| `@harperfast/hnsw` loads on the platform | absence is 503, not degraded: there is no JS graph to fall back to |
|
|
826
|
+
| The log retains entries back to the cursor | a cursor the log cannot resolve rebuilds from records; 503 for the rebuild's duration |
|
|
827
|
+
|
|
828
|
+
Not promised: a single total order across concurrent CRDT/source-resolution arrivals (both delivery
|
|
829
|
+
and replay re-read the authoritative record, so the index converges on what the primary store
|
|
830
|
+
resolved; divergence is bounded by candidate selection, which the exact rescore filters), byte-identical
|
|
831
|
+
graphs across nodes or rebuilds, or in-place format upgrades. Rebuild rate falls with graph size
|
|
832
|
+
(≈4,700 inserts/s at 100k, 1,242/s at 1M measured), so a 16M rebuild is hours of 503; a native batch
|
|
833
|
+
insert is the phase-3 follow-up.
|