@harperfast/harper 5.2.0 → 5.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/copyDb.ts +40 -11
- package/bin/harper.ts +20 -51
- package/bin/help.ts +216 -0
- package/components/Application.ts +236 -46
- package/components/ApplicationScope.ts +26 -0
- package/components/EntryHandler.ts +410 -105
- package/components/RuntimeModuleTracker.ts +189 -0
- package/components/Scope.ts +68 -24
- package/components/componentLoader.ts +70 -16
- package/components/deployLifecycle.ts +119 -33
- package/components/deploymentRecorder.ts +102 -11
- package/components/mcp/tools/operations.ts +6 -0
- package/components/operations.js +8 -6
- package/config/configUtils.ts +78 -20
- package/config/harperConfigEnvVars.ts +114 -17
- package/config-root.schema.json +10 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
- package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
- package/dataLayer/hdbInfoController.ts +8 -0
- package/dataLayer/schemaDescribe.ts +2 -1
- package/dist/bin/copyDb.js +33 -9
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/harper.d.ts +6 -0
- package/dist/bin/harper.js +18 -50
- package/dist/bin/harper.js.map +1 -1
- package/dist/bin/help.d.ts +8 -0
- package/dist/bin/help.js +192 -0
- package/dist/bin/help.js.map +1 -0
- package/dist/components/Application.d.ts +16 -1
- package/dist/components/Application.js +210 -38
- package/dist/components/Application.js.map +1 -1
- package/dist/components/ApplicationScope.d.ts +7 -0
- package/dist/components/ApplicationScope.js +22 -0
- package/dist/components/ApplicationScope.js.map +1 -1
- package/dist/components/EntryHandler.d.ts +4 -4
- package/dist/components/EntryHandler.js +386 -95
- package/dist/components/EntryHandler.js.map +1 -1
- package/dist/components/RuntimeModuleTracker.d.ts +11 -0
- package/dist/components/RuntimeModuleTracker.js +189 -0
- package/dist/components/RuntimeModuleTracker.js.map +1 -0
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +69 -22
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +69 -10
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deployLifecycle.d.ts +6 -2
- package/dist/components/deployLifecycle.js +109 -31
- package/dist/components/deployLifecycle.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +6 -0
- package/dist/components/deploymentRecorder.js +94 -12
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +6 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/operations.js +8 -6
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +6 -0
- package/dist/config/configUtils.js +78 -18
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/harperConfigEnvVars.js +108 -16
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
- package/dist/dataLayer/hdbInfoController.js +4 -0
- package/dist/dataLayer/hdbInfoController.js.map +1 -1
- package/dist/dataLayer/schemaDescribe.js +2 -1
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +57 -0
- package/dist/resources/DatabaseTransaction.js +284 -104
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -2
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Resources.js +22 -4
- package/dist/resources/Resources.js.map +1 -1
- package/dist/resources/Table.d.ts +8 -6
- package/dist/resources/Table.js +158 -31
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -6
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.js +27 -1
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/blob.d.ts +0 -1
- package/dist/resources/blob.js +15 -7
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +22 -5
- package/dist/resources/databases.js +118 -9
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/jsResource.d.ts +4 -26
- package/dist/resources/jsResource.js +5 -59
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -1
- package/dist/resources/models/Models.js +10 -1
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +9 -0
- package/dist/resources/models/backendRegistry.js +10 -0
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/openaiStream.d.ts +16 -1
- package/dist/resources/models/openaiStream.js +113 -21
- package/dist/resources/models/openaiStream.js.map +1 -1
- package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
- package/dist/resources/models/v1/chatCompletions.js +115 -0
- package/dist/resources/models/v1/chatCompletions.js.map +1 -0
- package/dist/resources/models/v1/embeddings.d.ts +11 -0
- package/dist/resources/models/v1/embeddings.js +71 -0
- package/dist/resources/models/v1/embeddings.js.map +1 -0
- package/dist/resources/models/v1/errors.d.ts +54 -0
- package/dist/resources/models/v1/errors.js +130 -0
- package/dist/resources/models/v1/errors.js.map +1 -0
- package/dist/resources/models/v1/index.d.ts +36 -0
- package/dist/resources/models/v1/index.js +75 -0
- package/dist/resources/models/v1/index.js.map +1 -0
- package/dist/resources/models/v1/models.d.ts +26 -0
- package/dist/resources/models/v1/models.js +44 -0
- package/dist/resources/models/v1/models.js.map +1 -0
- package/dist/resources/models/v1/translation.d.ts +133 -0
- package/dist/resources/models/v1/translation.js +298 -0
- package/dist/resources/models/v1/translation.js.map +1 -0
- package/dist/resources/roles.d.ts +1 -1
- package/dist/resources/roles.js +54 -7
- package/dist/resources/roles.js.map +1 -1
- package/dist/resources/search.d.ts +1 -1
- package/dist/resources/search.js +7 -3
- package/dist/resources/search.js.map +1 -1
- package/dist/security/jsLoader.js +84 -33
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/role.js +4 -0
- package/dist/security/role.js.map +1 -1
- package/dist/security/superUserGuard.d.ts +7 -0
- package/dist/security/superUserGuard.js +23 -0
- package/dist/security/superUserGuard.js.map +1 -0
- package/dist/security/tokenAuthentication.d.ts +0 -1
- package/dist/security/tokenAuthentication.js +6 -2
- package/dist/security/tokenAuthentication.js.map +1 -1
- package/dist/security/user.d.ts +6 -1
- package/dist/security/user.js +23 -1
- package/dist/security/user.js.map +1 -1
- package/dist/server/http.d.ts +23 -2
- package/dist/server/http.js +99 -12
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.d.ts +0 -2
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/multipartParser.js +9 -0
- package/dist/server/serverHelpers/multipartParser.js.map +1 -1
- package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
- package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
- package/dist/server/serverHelpers/registeredOperations.js +14 -13
- package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +19 -3
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +10 -7
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +57 -18
- package/dist/server/static.js.map +1 -1
- package/dist/server/storageReclamation.d.ts +17 -0
- package/dist/server/storageReclamation.js +90 -6
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/manageThreads.js +8 -0
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.js +20 -0
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +12 -0
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/sqlEngine/diff/differential.js +7 -3
- package/dist/sqlEngine/diff/differential.js.map +1 -1
- package/dist/sqlEngine/parser/normalizer.js +37 -9
- package/dist/sqlEngine/parser/normalizer.js.map +1 -1
- package/dist/sqlTranslator/index.js +6 -1
- package/dist/sqlTranslator/index.js.map +1 -1
- package/dist/upgrade/upgradePrompt.d.ts +2 -2
- package/dist/upgrade/upgradePrompt.js +22 -3
- package/dist/upgrade/upgradePrompt.js.map +1 -1
- package/dist/utility/environment/environmentManager.d.ts +11 -0
- package/dist/utility/environment/environmentManager.js +154 -0
- package/dist/utility/environment/environmentManager.js.map +1 -1
- package/dist/utility/errors/commonErrors.d.ts +1 -0
- package/dist/utility/errors/commonErrors.js +1 -0
- package/dist/utility/errors/commonErrors.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/install/installer.js +32 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/utility/lmdb/environmentUtility.js +2 -1
- package/dist/utility/lmdb/environmentUtility.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +7 -0
- package/dist/utility/logging/harper_logger.js +57 -24
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/npm-shrinkwrap.json +208 -198
- package/package.json +10 -8
- package/resources/DESIGN.md +1 -0
- package/resources/DatabaseTransaction.ts +313 -102
- package/resources/ResourceInterface.ts +8 -2
- package/resources/Resources.ts +22 -4
- package/resources/Table.ts +398 -259
- package/resources/analytics/write.ts +22 -20
- package/resources/auditStore.ts +32 -1
- package/resources/blob.ts +15 -8
- package/resources/databases.ts +123 -12
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
- package/resources/jsResource.ts +5 -62
- package/resources/models/Models.ts +14 -1
- package/resources/models/backendRegistry.ts +10 -0
- package/resources/models/openaiStream.ts +131 -19
- package/resources/models/v1/chatCompletions.ts +128 -0
- package/resources/models/v1/embeddings.ts +70 -0
- package/resources/models/v1/errors.ts +141 -0
- package/resources/models/v1/index.ts +72 -0
- package/resources/models/v1/models.ts +53 -0
- package/resources/models/v1/translation.ts +362 -0
- package/resources/roles.ts +67 -7
- package/resources/search.ts +8 -3
- package/security/jsLoader.ts +84 -30
- package/security/role.ts +7 -0
- package/security/superUserGuard.ts +20 -0
- package/security/tokenAuthentication.ts +6 -3
- package/security/user.ts +26 -1
- package/server/DESIGN.md +45 -34
- package/server/http.ts +100 -13
- package/server/operationsServer.ts +0 -2
- package/server/serverHelpers/multipartParser.ts +9 -0
- package/server/serverHelpers/operationAuthorizationState.ts +11 -0
- package/server/serverHelpers/registeredOperations.ts +19 -15
- package/server/serverHelpers/serverHandlers.js +20 -3
- package/server/serverHelpers/serverUtilities.ts +10 -7
- package/server/static.ts +75 -20
- package/server/storageReclamation.ts +104 -8
- package/server/threads/manageThreads.js +8 -0
- package/server/threads/socketRouter.ts +20 -0
- package/server/threads/threadServer.js +11 -0
- package/sqlTranslator/index.ts +6 -1
- package/static/defaultConfig.yaml +2 -0
- package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
- package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
- package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
- package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
- package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
- package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
- package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
- package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
- package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
- package/studio/web/assets/index-CDeqVx71.css +1 -0
- package/studio/web/assets/index-Sevt3MVx.js +819 -0
- package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
- package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
- package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
- package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
- package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
- package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
- package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
- package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
- package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
- package/studio/web/assets/notifications-vehqyOLM.js +2 -0
- package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
- package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
- package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
- package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
- package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
- package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
- package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
- package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
- package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
- package/studio/web/assets/register-BDy01-q3.js +3 -0
- package/studio/web/assets/register-BDy01-q3.js.map +1 -0
- package/studio/web/assets/register-CQHRwNP4.js +3 -0
- package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
- package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
- package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
- package/studio/web/assets/setup-I15FAF9H.js +3 -0
- package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
- package/studio/web/assets/status-BF1eJshJ.js +62 -0
- package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
- package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
- package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
- package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
- package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
- package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
- package/studio/web/index.html +6 -7
- package/upgrade/upgradePrompt.ts +22 -3
- package/utility/environment/environmentManager.ts +152 -0
- package/utility/errors/commonErrors.ts +2 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/install/installer.ts +37 -0
- package/utility/lmdb/environmentUtility.ts +2 -1
- package/utility/logging/harper_logger.ts +57 -26
- package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
- package/studio/web/assets/index-Bh_CNAHr.js +0 -809
- package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
- package/studio/web/assets/index-C8Mv9kHV.css +0 -1
- package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
- package/studio/web/assets/notifications-CwKhipK7.js +0 -2
- package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
- package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
- package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
- package/studio/web/assets/setup-B56Oz1_u.js +0 -3
- package/studio/web/assets/status-BAod7p3o.js +0 -62
- package/studio/web/assets/status-BAod7p3o.js.map +0 -1
- package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
|
@@ -397,26 +397,28 @@ function storeDBSizeMetrics(analyticsTable: Table, databases: Databases) {
|
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
400
|
+
async function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
|
|
401
|
+
await Promise.all(
|
|
402
|
+
Object.entries(databases).map(async ([db, tables]) => {
|
|
403
|
+
try {
|
|
404
|
+
const [firstTable] = Object.values(tables);
|
|
405
|
+
const storageStats = await firstTable?.getStorageStats();
|
|
406
|
+
if (!storageStats) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const metric = {
|
|
410
|
+
metric: METRIC.STORAGE_VOLUME,
|
|
411
|
+
database: db,
|
|
412
|
+
...storageStats,
|
|
413
|
+
};
|
|
414
|
+
storeMetric(analyticsTable, metric);
|
|
415
|
+
log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
// a table or db was deleted, could get an error here
|
|
418
|
+
log.warn?.(`Error getting DB volume metrics`, error);
|
|
407
419
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
database: db,
|
|
411
|
-
...storageStats,
|
|
412
|
-
};
|
|
413
|
-
storeMetric(analyticsTable, metric);
|
|
414
|
-
log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
|
|
415
|
-
} catch (error) {
|
|
416
|
-
// a table or db was deleted, could get an error here
|
|
417
|
-
log.warn?.(`Error getting DB volume metrics`, error);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
+
})
|
|
421
|
+
);
|
|
420
422
|
}
|
|
421
423
|
|
|
422
424
|
// RocksDB stat names are kebab-case with a "rocksdb." prefix (e.g. "rocksdb.block.cache.hit");
|
|
@@ -1047,7 +1049,7 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
|
|
|
1047
1049
|
storeDBSizeMetrics(analyticsTable, databases);
|
|
1048
1050
|
|
|
1049
1051
|
// database storage volume metrics
|
|
1050
|
-
storeVolumeMetrics(analyticsTable, databases);
|
|
1052
|
+
await storeVolumeMetrics(analyticsTable, databases);
|
|
1051
1053
|
|
|
1052
1054
|
// rocksdb engine stats (only for RocksDB-backed databases)
|
|
1053
1055
|
const rocksDBPeriod = lastRocksDBStatsTime ? now - lastRocksDBStatsTime : undefined;
|
package/resources/auditStore.ts
CHANGED
|
@@ -111,6 +111,10 @@ const MAX_DELETES_PER_CLEANUP = 1000;
|
|
|
111
111
|
// setTimeout silently falls back to 1ms for delays past this, which would turn the backoff into the
|
|
112
112
|
// hot loop it is meant to avoid — a `logging.auditRetention` over ~248 days reaches it via retention/10
|
|
113
113
|
const MAX_CLEANUP_DELAY = 2 ** 31 - 1;
|
|
114
|
+
// separate mint/read latches so a legacy-entry read warn can't mask the still-minting signal;
|
|
115
|
+
// mint latch keyed per (table, type) so one entry type can't silence another's producer stack
|
|
116
|
+
const warnedBodylessMints = new Set<string>();
|
|
117
|
+
const warnedBodylessTables = new Set<number>();
|
|
114
118
|
const FLOAT_TARGET = new Float64Array(1);
|
|
115
119
|
const FLOAT_BUFFER = new Uint8Array(FLOAT_TARGET.buffer);
|
|
116
120
|
let DEFAULT_AUDIT_CLEANUP_DELAY = 10000; // default delay of 10 seconds
|
|
@@ -416,10 +420,24 @@ export function createAuditEntry(auditRecord: AuditRecord, start = 0) {
|
|
|
416
420
|
originatingOperation,
|
|
417
421
|
previousAdditionalAuditRefs,
|
|
418
422
|
} = auditRecord;
|
|
419
|
-
|
|
423
|
+
let action = EVENT_TYPES[type];
|
|
420
424
|
if (!action) {
|
|
421
425
|
throw new Error(`Invalid audit entry type ${type}`);
|
|
422
426
|
}
|
|
427
|
+
if (action & (HAS_RECORD | HAS_PARTIAL_RECORD) && !encodedRecord?.length) {
|
|
428
|
+
// Readers decode the remainder whenever HAS_RECORD is set, so an audit-only commit minted with
|
|
429
|
+
// no body must not advertise one (#2153). HAS_PARTIAL_RECORD is kept: it also drives
|
|
430
|
+
// record-history reconstruction, and the read path tolerates the empty body.
|
|
431
|
+
if (!warnedBodylessMints.has(`${tableId}:${type}`)) {
|
|
432
|
+
warnedBodylessMints.add(`${tableId}:${type}`);
|
|
433
|
+
// the Error's stack identifies which write path delivered the missing value
|
|
434
|
+
harperLogger.warn(
|
|
435
|
+
`Audit entry (${type}) for record ${recordId} in table ${tableId} has no record body`,
|
|
436
|
+
new Error('bodyless audit mint')
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
action &= ~HAS_RECORD;
|
|
440
|
+
}
|
|
423
441
|
let position = start + 1;
|
|
424
442
|
if (previousVersion) {
|
|
425
443
|
if (previousVersion > 1) ENTRY_DATAVIEW.setFloat64(start, previousVersion);
|
|
@@ -621,6 +639,19 @@ export function readAuditEntry(buffer: Uint8Array, start = 0, end = undefined):
|
|
|
621
639
|
},
|
|
622
640
|
getValue(store, fullRecord?, auditTime?) {
|
|
623
641
|
if (action & HAS_RECORD || (action & HAS_PARTIAL_RECORD && !fullRecord)) {
|
|
642
|
+
if (decoder.position >= (end ?? buffer.byteLength)) {
|
|
643
|
+
// Entry advertises a record but has no body (minted before #2153): nothing to decode, and
|
|
644
|
+
// return undefined rather than falling through — this branch means the caller asked for the
|
|
645
|
+
// entry's own content (full-record consumers with an auditTime never enter it for partials
|
|
646
|
+
// and still reconstruct below). Warn latched per table: this getter runs inside range scans.
|
|
647
|
+
if (!warnedBodylessTables.has(tableId)) {
|
|
648
|
+
warnedBodylessTables.add(tableId);
|
|
649
|
+
harperLogger.warn(
|
|
650
|
+
`Audit entry (${EVENT_TYPES[action & 0xf]}) for table ${tableId} advertises a record but has no body; treating as having no record`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
624
655
|
if (!value) {
|
|
625
656
|
value = decodeFromDatabase(
|
|
626
657
|
// the audit value has no on-disk timestamp/metadata prefix (the audit entry carries
|
package/resources/blob.ts
CHANGED
|
@@ -55,7 +55,6 @@ type StorageInfo = {
|
|
|
55
55
|
recordId?: number;
|
|
56
56
|
contentBuffer?: any;
|
|
57
57
|
source?: Readable;
|
|
58
|
-
storageBuffer?: Buffer;
|
|
59
58
|
compress?: boolean;
|
|
60
59
|
flush?: boolean;
|
|
61
60
|
start?: number;
|
|
@@ -829,7 +828,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
829
828
|
storageInfoForBlob.set(slicedBlob, slicedStorageInfo);
|
|
830
829
|
if (this.size != undefined)
|
|
831
830
|
slicedBlob.size = (end == undefined ? this.size : Math.min(end, this.size)) - (start ?? 0);
|
|
832
|
-
} else if (sourceStorageInfo?.contentBuffer
|
|
831
|
+
} else if (sourceStorageInfo?.contentBuffer) {
|
|
833
832
|
const slicedStorageInfo = {
|
|
834
833
|
...sourceStorageInfo,
|
|
835
834
|
contentBuffer: sourceStorageInfo.contentBuffer.subarray(start, end),
|
|
@@ -1340,8 +1339,11 @@ function getNextStorageIndex(blobStoragePaths: string[], fileId: number) {
|
|
|
1340
1339
|
}
|
|
1341
1340
|
if (((blobStoragePaths as any).lastUpdated ?? 0) + 60000 < now) {
|
|
1342
1341
|
(blobStoragePaths as any).lastUpdated = now;
|
|
1343
|
-
// create a new frequency table based on the available space
|
|
1344
|
-
|
|
1342
|
+
// create a new frequency table based on the available space; fire-and-forget, so a
|
|
1343
|
+
// transient stat/mkdir failure must not become an unhandled rejection
|
|
1344
|
+
createFrequencyTableForStoragePaths(blobStoragePaths).catch((error) => {
|
|
1345
|
+
logger.warn?.('Error creating storage path frequency table', error);
|
|
1346
|
+
});
|
|
1345
1347
|
}
|
|
1346
1348
|
const nextIndex = (blobStoragePaths as any).frequencyTable[fileId % FREQUENCY_TABLE_SIZE];
|
|
1347
1349
|
return nextIndex;
|
|
@@ -1356,6 +1358,10 @@ async function createFrequencyTableForStoragePaths(blobStoragePaths: string[]) {
|
|
|
1356
1358
|
if (!statfs) return; // statfs is not available on all older node versions
|
|
1357
1359
|
const availableSpaces = await Promise.all(
|
|
1358
1360
|
blobStoragePaths.map(async (path) => {
|
|
1361
|
+
// This compares MULTIPLE distinct paths against each other, so it always needs the
|
|
1362
|
+
// real per-path number: quota-status.json (see getStorageSpaceStats, #1976) is a
|
|
1363
|
+
// single instance-wide figure that can't tell two disks apart, and would collapse
|
|
1364
|
+
// every path in a multi-volume STORAGE_BLOBPATHS config to the same "available" value.
|
|
1359
1365
|
let stats: StatsFs;
|
|
1360
1366
|
try {
|
|
1361
1367
|
stats = await statfs(path);
|
|
@@ -1733,10 +1739,14 @@ addExtension({
|
|
|
1733
1739
|
throw new Error('No store specified, cannot load blob from storage');
|
|
1734
1740
|
}
|
|
1735
1741
|
} else {
|
|
1742
|
+
// contentBuffer is a *copy* (copyingUnpacker uses copyBuffers), so it stays valid for the
|
|
1743
|
+
// lifetime of the blob. Do not retain the raw ext-body `buffer` here: it is a view into the
|
|
1744
|
+
// store's read buffer, which is recycled by later reads. Keeping it and re-emitting it on
|
|
1745
|
+
// re-encode (e.g. a read-modify-write / REST PATCH) serialized whatever foreign bytes had
|
|
1746
|
+
// since overwritten that buffer, corrupting the record (harper#2103).
|
|
1736
1747
|
storageInfoForBlob.set(blob, {
|
|
1737
1748
|
storageIndex: 0,
|
|
1738
1749
|
fileId: null,
|
|
1739
|
-
storageBuffer: buffer as any,
|
|
1740
1750
|
contentBuffer: blobInfo[1] as any,
|
|
1741
1751
|
});
|
|
1742
1752
|
blob.size = blobInfo[1]?.length;
|
|
@@ -1757,9 +1767,6 @@ addExtension({
|
|
|
1757
1767
|
if (blob.type) options.type = blob.type;
|
|
1758
1768
|
if (blob.size !== undefined) options.size = blob.size;
|
|
1759
1769
|
if (storageInfo) {
|
|
1760
|
-
if (storageInfo.storageBuffer) {
|
|
1761
|
-
return storageInfo.storageBuffer;
|
|
1762
|
-
}
|
|
1763
1770
|
if (
|
|
1764
1771
|
storageInfo.contentBuffer &&
|
|
1765
1772
|
(storageInfo.contentBuffer?.length < FILE_STORAGE_THRESHOLD || blob.saveInRecord)
|
package/resources/databases.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { handleLocalTimeForGets } from './RecordEncoder.ts';
|
|
|
33
33
|
import { deleteRootBlobPathsForDB } from './blob.ts';
|
|
34
34
|
import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
|
|
35
35
|
import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
|
|
36
|
-
import { RocksDatabase, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
|
|
36
|
+
import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
|
|
37
37
|
import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
|
|
38
38
|
import { replayLogs } from './replayLogs.ts';
|
|
39
39
|
import { totalmem } from 'node:os';
|
|
@@ -98,6 +98,80 @@ const DEFAULT_DATABASE_NAME = 'data';
|
|
|
98
98
|
const DEFINED_TABLES = Symbol('defined-tables');
|
|
99
99
|
const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
|
|
100
100
|
initSync();
|
|
101
|
+
/**
|
|
102
|
+
* The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
|
|
103
|
+
* or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
|
|
104
|
+
*
|
|
105
|
+
* Resolved on the first open and then frozen, deliberately. RocksDB fixes a column family's codec
|
|
106
|
+
* for as long as it is open and rejects a reopen that disagrees, and Harper's worker threads share
|
|
107
|
+
* one process-wide column-family registry — so every open, in every thread, has to resolve the same
|
|
108
|
+
* value. Re-reading config per open does not guarantee that: on a fresh install the system families
|
|
109
|
+
* are created by `mountHdb()` before the config file exists, so the main thread would resolve
|
|
110
|
+
* nothing and the workers would resolve the configured codec, after which `__dbis__` cannot be
|
|
111
|
+
* reopened and Harper fails with "The system database failed to load". The installer stages this
|
|
112
|
+
* value before `mountHdb()` (see utility/install/installer.ts) so that first open already sees it.
|
|
113
|
+
*
|
|
114
|
+
* Unset is NOT "use the build default" for a family that already exists — see toRocksCompression.
|
|
115
|
+
*/
|
|
116
|
+
let resolvedRocksCompression: string | undefined;
|
|
117
|
+
let rocksCompressionResolved = false;
|
|
118
|
+
|
|
119
|
+
export function getRocksCompression(): string | undefined {
|
|
120
|
+
if (!rocksCompressionResolved) {
|
|
121
|
+
resolvedRocksCompression = readDatabaseCodec();
|
|
122
|
+
rocksCompressionResolved = true;
|
|
123
|
+
}
|
|
124
|
+
return resolvedRocksCompression;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Test-only: un-freezes the resolved codec. Production code never calls this — the freeze is the
|
|
129
|
+
* invariant (see getRocksCompression above) — but a test process runs many unrelated test files in
|
|
130
|
+
* one process, so whichever file happens to open a RocksDatabase first freezes this for everyone
|
|
131
|
+
* after it. Tests that need to exercise config changes call this to get back to the unresolved state.
|
|
132
|
+
*/
|
|
133
|
+
export function resetRocksCompression(): void {
|
|
134
|
+
resolvedRocksCompression = undefined;
|
|
135
|
+
rocksCompressionResolved = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The codec every column family in this process opens under.
|
|
140
|
+
*
|
|
141
|
+
* Compression is a deployment setting, not a per-table one. RocksDB opens all of a database's
|
|
142
|
+
* column families in one call, so the codec has to be decided before the first open — which is
|
|
143
|
+
* before Harper has read any table's metadata (that catalog is itself one of the families being
|
|
144
|
+
* opened). Resolving one codec from configuration and applying it to every family is what makes
|
|
145
|
+
* that possible; it is passed with `compressionForAllColumnFamilies` so families this process
|
|
146
|
+
* never names individually adopt it too, which is what lets a database created before the codec
|
|
147
|
+
* existed start compressing.
|
|
148
|
+
*
|
|
149
|
+
* `storage.rocks.compression` names a codec outright. Otherwise `storage.compression` (default
|
|
150
|
+
* true) decides enabled-or-not and the build default fills in the algorithm. Per-table metadata
|
|
151
|
+
* still records the LMDB-era boolean, but no longer selects: a table persisted as disabled inside
|
|
152
|
+
* a deployment that enables compression would need its own codec, and it cannot have one.
|
|
153
|
+
*/
|
|
154
|
+
function readDatabaseCodec(): string | undefined {
|
|
155
|
+
const explicit = readRocksCompressionConfig();
|
|
156
|
+
if (explicit) return explicit;
|
|
157
|
+
return toRocksCompression(getDefaultCompression()) as string | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readRocksCompressionConfig(): string | undefined {
|
|
161
|
+
const configured = envGet(CONFIG_PARAMS.STORAGE_ROCKS_COMPRESSION);
|
|
162
|
+
if (configured === undefined || configured === null || configured === '') return undefined;
|
|
163
|
+
const requested = String(configured).trim().toLowerCase();
|
|
164
|
+
if (!requested) return undefined;
|
|
165
|
+
// Rejected here rather than at the open: an unsupported name throws inside RocksDatabase.open,
|
|
166
|
+
// which surfaces as the system database failing to load partway through startup.
|
|
167
|
+
if (!supportedCompression.includes(requested)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`storage.rocks.compression="${requested}" is not available in this build of @harperfast/rocksdb-js. Supported: ${supportedCompression.join(', ')}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return requested;
|
|
173
|
+
}
|
|
174
|
+
|
|
101
175
|
// I don't know if this is the best place for this, but somewhere we need to specify which tables
|
|
102
176
|
// replicate by default:
|
|
103
177
|
export const NON_REPLICATING_SYSTEM_TABLES = [
|
|
@@ -177,22 +251,51 @@ export const tables: Tables = Object.create(null);
|
|
|
177
251
|
export const databases: Databases = Object.create(null);
|
|
178
252
|
|
|
179
253
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
254
|
+
* Codec used to honor an "enabled, unspecified" compression setting, or `undefined` where the
|
|
255
|
+
* native build cannot provide it (in which case the request degrades to the build default rather
|
|
256
|
+
* than throwing).
|
|
257
|
+
*/
|
|
258
|
+
const DEFAULT_ENABLED_CODEC = supportedCompression.includes('lz4') ? 'lz4' : undefined;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata carries
|
|
262
|
+
* values where a defined falsy value (false, '') means compression was explicitly disabled, and
|
|
263
|
+
* `true` / `{ threshold, ... }` mean enabled with defaults — `storage.compression` defaults to
|
|
264
|
+
* `true` (defaultConfig.yaml), so essentially every pre-existing table asked for compression.
|
|
265
|
+
*
|
|
266
|
+
* "Enabled" resolves to an explicit codec rather than to unset. Unset is not equivalent: RocksDB
|
|
267
|
+
* persists the codec per column family and a reopen that requests nothing inherits what the family
|
|
268
|
+
* already has, applying the build default only when the family does not yet exist. Leaving these
|
|
269
|
+
* unset therefore silently ignores the operator's request on every database created before the
|
|
270
|
+
* native build carried codecs — it keeps writing uncompressed forever, while a brand-new database
|
|
271
|
+
* gets lz4. Naming the codec makes the setting mean the same thing in both cases.
|
|
272
|
+
*
|
|
273
|
+
* This governs newly written files; existing SSTs keep their codec until write traffic rewrites
|
|
274
|
+
* them (`db.compact()` will not — see getRocksCompression above).
|
|
185
275
|
*/
|
|
186
276
|
export function toRocksCompression(compression: unknown): unknown {
|
|
187
|
-
if (compression ===
|
|
188
|
-
if (
|
|
277
|
+
if (compression === undefined) return undefined;
|
|
278
|
+
if (!compression) return 'none';
|
|
279
|
+
// An object carrying an explicit `algorithm` is already a rocksdb-js request; anything else
|
|
280
|
+
// (`true`, or an LMDB descriptor like { startingOffset, threshold }) is "enabled, unspecified".
|
|
281
|
+
if (compression === true || (typeof compression === 'object' && !(compression as { algorithm?: unknown }).algorithm))
|
|
282
|
+
return DEFAULT_ENABLED_CODEC;
|
|
189
283
|
return compression;
|
|
190
284
|
}
|
|
191
285
|
|
|
192
286
|
function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
|
|
193
287
|
options.disableWAL ??= true;
|
|
194
288
|
const legacyOptions = options as { compression?: unknown };
|
|
195
|
-
|
|
289
|
+
// A configured codec applies to every column family, overriding whatever per-table metadata
|
|
290
|
+
// carries — that metadata records the LMDB-era boolean, so without this there is no way to
|
|
291
|
+
// select a RocksDB codec for a deployment.
|
|
292
|
+
// One codec for every column family, and applied to every family this open touches — not just
|
|
293
|
+
// the one being named. RocksDB opens them all at once and a family's codec cannot change while
|
|
294
|
+
// it is open, so a family this process never names individually would otherwise stay on
|
|
295
|
+
// whatever it was created with, forever.
|
|
296
|
+
const databaseCodec = getRocksCompression();
|
|
297
|
+
legacyOptions.compression = databaseCodec;
|
|
298
|
+
if (databaseCodec) (options as { compressionForAllColumnFamilies?: boolean }).compressionForAllColumnFamilies = true;
|
|
196
299
|
// Apply read-only mode if enabled
|
|
197
300
|
if (isReadOnlyMode()) {
|
|
198
301
|
options.readOnly = true;
|
|
@@ -588,7 +691,7 @@ function initStores(
|
|
|
588
691
|
...internalDbiInit,
|
|
589
692
|
disableWAL: false,
|
|
590
693
|
name: INTERNAL_DBIS_NAME,
|
|
591
|
-
} as any)
|
|
694
|
+
} as any);
|
|
592
695
|
} else {
|
|
593
696
|
attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
|
|
594
697
|
}
|
|
@@ -1350,7 +1453,11 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
|
|
|
1350
1453
|
// Enable cache (WeakLRUCache + VT) for all custom-object index stores so the VT is
|
|
1351
1454
|
// available before resolveIndexFormat decides the format. Versioned stores need the VT
|
|
1352
1455
|
// for cached traversal; legacy stores pay a small per-write cache.delete() overhead only.
|
|
1353
|
-
dbi = openRocksDatabase(rootStore.path, {
|
|
1456
|
+
dbi = openRocksDatabase(rootStore.path, {
|
|
1457
|
+
...dbiInit,
|
|
1458
|
+
name: dbiKey,
|
|
1459
|
+
cache: isCustomObjectIndex,
|
|
1460
|
+
} as any) as any;
|
|
1354
1461
|
(dbi as any).rootStore = rootStore;
|
|
1355
1462
|
// Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
|
|
1356
1463
|
// transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
|
|
@@ -1573,6 +1680,10 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
|
|
|
1573
1680
|
clearInterruptedDropEntries(rootStore.path, tableName);
|
|
1574
1681
|
}
|
|
1575
1682
|
if (rootStore instanceof RocksDatabase) {
|
|
1683
|
+
// Usually a genuinely new column family (existingTableMeta above found no catalog
|
|
1684
|
+
// entry), but an interrupted drop just completed above can leave the physical CF
|
|
1685
|
+
// behind under its old codec even though the catalog entry is gone — same fallback
|
|
1686
|
+
// as the reconcile paths covers that remnant case too.
|
|
1576
1687
|
primaryStore = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiName, cache: true } as any);
|
|
1577
1688
|
} else {
|
|
1578
1689
|
primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
|
|
@@ -2183,7 +2294,7 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
|
|
|
2183
2294
|
if (rootStore instanceof RocksDatabase) {
|
|
2184
2295
|
for (const columnName of (rootStore as any).columns) {
|
|
2185
2296
|
if (columnName.startsWith(tableName + '/')) {
|
|
2186
|
-
const columnStore = openRocksDatabase(rootStore.path, { name: columnName }
|
|
2297
|
+
const columnStore = openRocksDatabase(rootStore.path, { name: columnName });
|
|
2187
2298
|
try {
|
|
2188
2299
|
columnStore.dropSync();
|
|
2189
2300
|
} catch (error) {
|
|
@@ -3,7 +3,6 @@ import { FLOAT32_OPTIONS } from 'msgpackr';
|
|
|
3
3
|
import { loggerWithTag } from '../../utility/logging/logger.ts';
|
|
4
4
|
import { ClientError } from '../../utility/errors/hdbError.ts';
|
|
5
5
|
import type { Id } from '../../resources/ResourceInterface.ts';
|
|
6
|
-
import { RocksDatabase } from '@harperfast/rocksdb-js';
|
|
7
6
|
import { SKIP } from '@harperfast/extended-iterable';
|
|
8
7
|
|
|
9
8
|
const logger = loggerWithTag('HNSW');
|
|
@@ -50,13 +49,35 @@ function dequantizeInt8(q: Int8Array, scale: number): number[] {
|
|
|
50
49
|
// cap deliberately favors latency — apps wanting higher recall set efConstructionSearch or a per-query
|
|
51
50
|
// ef. Tune as graph build quality / larger-N data improves.
|
|
52
51
|
const AUTO_EF_BASE = 100;
|
|
53
|
-
|
|
52
|
+
// The index store holds a graph node plus a primary-key mapping per record, so a key count is twice
|
|
53
|
+
// the node count. Sizes here are in nodes; this converts back for the one consumer still calibrated
|
|
54
|
+
// against keys.
|
|
55
|
+
const INDEX_KEYS_PER_NODE = 2;
|
|
56
|
+
// Graph nodes at which ef equals AUTO_EF_BASE. Originally expressed per index-store key against a
|
|
57
|
+
// reference of 1000, so 500 nodes is the same point. The node count is a high-water mark rather than
|
|
58
|
+
// a live count, so the resolved ef can differ from that formula by one at a rounding boundary.
|
|
59
|
+
const AUTO_EF_REF = 500;
|
|
54
60
|
const AUTO_EF_MAX = 512;
|
|
55
61
|
function autoScaleEf(nodeCount: number): number {
|
|
56
62
|
const scaled = Math.round(AUTO_EF_BASE * Math.sqrt(Math.max(1, nodeCount / AUTO_EF_REF)));
|
|
57
63
|
return Math.min(AUTO_EF_MAX, Math.max(AUTO_EF_BASE, scaled));
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
// Candidate-list size used when searching a layer above 0. Those layers only supply the entry point
|
|
67
|
+
// for the next layer down, so a greedy walk is enough; a larger value costs work proportional to the
|
|
68
|
+
// layer's population without improving the entry point it hands off.
|
|
69
|
+
const ROUTING_EF = 1;
|
|
70
|
+
// Ceiling on the ef a query's own `offset + limit` can ask for. `limit` is unprivileged and set on
|
|
71
|
+
// every request, so this bounds what a caller can make one thread do synchronously: layer 0 holds
|
|
72
|
+
// `ef` candidates in a sorted array with an O(len) insert. Kept within a small multiple of
|
|
73
|
+
// AUTO_EF_MAX so the worst case stays the same order as the index's own auto-scaled ceiling; a
|
|
74
|
+
// caller who genuinely wants more sets an explicit `ef` and owns the cost.
|
|
75
|
+
const LIMIT_EF_MAX = 4 * AUTO_EF_MAX;
|
|
76
|
+
// How long a resolved graph size is reused before it is looked up again (see approximateNodeCount).
|
|
77
|
+
// ef moves with the square root of the count and is capped, so a slightly stale size is immaterial;
|
|
78
|
+
// this only has to be short enough that a table growing from empty picks up a larger ef promptly.
|
|
79
|
+
const NODE_COUNT_TTL = 10_000;
|
|
80
|
+
|
|
60
81
|
class MinHeap {
|
|
61
82
|
private data: Candidate[] = [];
|
|
62
83
|
get size() {
|
|
@@ -181,6 +202,8 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
181
202
|
// frozen node the object store hands back. WeakMap so entries are collected when the store evicts
|
|
182
203
|
// the frozen node — without it, every cache hit on a frozen node would re-slice and re-clone.
|
|
183
204
|
private convertedNodes = new WeakMap<object, any>();
|
|
205
|
+
private nodeCount = 0;
|
|
206
|
+
private nodeCountAt = 0;
|
|
184
207
|
constructor(indexStore: any, options: any) {
|
|
185
208
|
this.indexStore = indexStore;
|
|
186
209
|
if (indexStore) {
|
|
@@ -334,17 +357,12 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
334
357
|
this.indexStore.put(ENTRY_POINT, nodeId, options);
|
|
335
358
|
}
|
|
336
359
|
|
|
337
|
-
//
|
|
360
|
+
// Pure descent — only neighbors[0] is used — so it runs greedily for the same reason
|
|
361
|
+
// search() does. The connection-building pass below keeps efConstruction: it selects the
|
|
362
|
+
// edges that get stored.
|
|
338
363
|
while (currentLevel > level) {
|
|
339
364
|
// Search for closest neighbors at current level
|
|
340
|
-
const neighbors = this.searchLayer(
|
|
341
|
-
vector,
|
|
342
|
-
entryPointId,
|
|
343
|
-
entryPoint,
|
|
344
|
-
this.efConstruction,
|
|
345
|
-
currentLevel,
|
|
346
|
-
options
|
|
347
|
-
);
|
|
365
|
+
const neighbors = this.searchLayer(vector, entryPointId, entryPoint, ROUTING_EF, currentLevel, options);
|
|
348
366
|
|
|
349
367
|
if (neighbors.length > 0) {
|
|
350
368
|
entryPointId = neighbors[0].id; // closest neighbor becomes new entry point
|
|
@@ -692,6 +710,34 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
692
710
|
}
|
|
693
711
|
}
|
|
694
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Number of nodes in the graph, for the ef auto-scale. Must stay O(1): it runs per query, and an
|
|
715
|
+
* exact count is a full key scan. RocksDB's `estimate-num-keys` is not a usable substitute — it
|
|
716
|
+
* counts unreconciled overwrites, which graph construction produces in bulk. See DESIGN.md for the
|
|
717
|
+
* measurements behind both. The memo is the only gate on how often the size is resolved; nothing on
|
|
718
|
+
* the query path may bypass it, or the O(1) lookup becomes per-query work again.
|
|
719
|
+
*/
|
|
720
|
+
private approximateNodeCount(): number {
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
if (this.nodeCountAt > 0 && now - this.nodeCountAt < NODE_COUNT_TTL) return this.nodeCount;
|
|
723
|
+
this.nodeCount = this.resolveNodeCount();
|
|
724
|
+
this.nodeCountAt = now;
|
|
725
|
+
return this.nodeCount;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** O(1) node count — the shared id counter, else a single reverse seek to the largest node id. */
|
|
729
|
+
private resolveNodeCount(): number {
|
|
730
|
+
if (this.idIncrementer) return Number(Atomics.load(this.idIncrementer, 0));
|
|
731
|
+
try {
|
|
732
|
+
for (const key of this.indexStore.getKeys({ reverse: true, limit: 1, start: Infinity, end: 0 })) {
|
|
733
|
+
if (typeof key === 'number') return key + 1;
|
|
734
|
+
}
|
|
735
|
+
} catch (error) {
|
|
736
|
+
logger.debug?.('could not resolve node count from the largest node id', error);
|
|
737
|
+
}
|
|
738
|
+
return 0; // empty (or unreadable) graph — autoScaleEf falls back to its floor
|
|
739
|
+
}
|
|
740
|
+
|
|
695
741
|
private safeGetSync(key: any, options?: any): any {
|
|
696
742
|
try {
|
|
697
743
|
let node = this.indexStore.getSync(key, options);
|
|
@@ -945,7 +991,12 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
945
991
|
// returns true are admitted to the result list at layer 0; routing is unaffected. Composed by
|
|
946
992
|
// search.ts from companion AND conditions and caller-supplied vector/row filters. Must be
|
|
947
993
|
// synchronous and side-effect free. JS-API only (never from a REST query string).
|
|
948
|
-
filter?: (primaryKey: Id) => boolean
|
|
994
|
+
filter?: (primaryKey: Id) => boolean,
|
|
995
|
+
// offset + limit for a bounded query. A layer-0 search returns at most `ef` candidates, so a
|
|
996
|
+
// query asking for more rows than that used to come back short with no error — capped at 512
|
|
997
|
+
// (AUTO_EF_MAX) however large the limit was. Raising ef to cover the request keeps `limit`
|
|
998
|
+
// meaningful; the caller pays for what it asked for.
|
|
999
|
+
minResults?: number
|
|
949
1000
|
) {
|
|
950
1001
|
let limit: number | undefined; // only set for threshold comparators; 0 is a valid threshold (e.g. dotProduct)
|
|
951
1002
|
let limitInclusive = false; // true for `le`, false for `lt`
|
|
@@ -978,18 +1029,33 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
978
1029
|
// Resolve search ef: per-query ef wins; else an explicitly-configured efConstructionSearch;
|
|
979
1030
|
// else auto-scale with the graph size so recall holds as the table grows.
|
|
980
1031
|
let effectiveEf = this.efConstructionSearch;
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1032
|
+
const explicitEf = ef !== undefined && ef > 0;
|
|
1033
|
+
if (explicitEf) effectiveEf = ef;
|
|
1034
|
+
else if (!this.efSearchConfigured) effectiveEf = autoScaleEf(this.approximateNodeCount());
|
|
1035
|
+
// The ef the index chose for itself, before any limit-derived widening. The filter budget below
|
|
1036
|
+
// stays calibrated against this rather than against what a caller's `limit` asked for.
|
|
1037
|
+
const resolvedEf = effectiveEf;
|
|
1038
|
+
// A bounded query must be able to come back full: layer 0 keeps at most `ef` candidates, so a
|
|
1039
|
+
// limit above the resolved ef truncated the result set with no error. Widen the candidate list
|
|
1040
|
+
// to cover the request, up to LIMIT_EF_MAX — `ef` sizes a synchronous traversal that holds every
|
|
1041
|
+
// admitted candidate in a sorted array with an O(len) insert, so an unbounded one lets a plain
|
|
1042
|
+
// `limit` stall the thread. Past the ceiling the result set is still short, as it was before.
|
|
1043
|
+
// A per-query `ef` is left authoritative: it is an explicit cost ceiling, and a caller who sets
|
|
1044
|
+
// one has said what they are willing to spend.
|
|
1045
|
+
// The ceiling is the only bound: clamping to the graph size as well would need a count exact as
|
|
1046
|
+
// of this query — the memo reads low while a table grows, truncating the very limit this
|
|
1047
|
+
// honours — and an ef above the node count is free, the traversal ending at the graph, not ef.
|
|
1048
|
+
if (minResults !== undefined && !explicitEf && minResults > effectiveEf) {
|
|
1049
|
+
effectiveEf = Math.max(effectiveEf, Math.min(minResults, LIMIT_EF_MAX));
|
|
987
1050
|
}
|
|
988
1051
|
// Predicate-aware traversal budget (#1241): matches accrue slower than visits under a selective
|
|
989
1052
|
// filter, so bound layer-0 work at ef * filterExpansion nodes. Only built when a filter is active.
|
|
1053
|
+
// Deliberately `resolvedEf`, not the limit-widened ef: this budget is what stops a selective
|
|
1054
|
+
// filter crawling the whole graph, and multiplying it by a caller's limit would turn a filtered
|
|
1055
|
+
// vector query into a record-loading scan.
|
|
990
1056
|
const filterState: FilterState | undefined = filter
|
|
991
1057
|
? {
|
|
992
|
-
maxVisits:
|
|
1058
|
+
maxVisits: resolvedEf * (filterExpansion && filterExpansion > 0 ? filterExpansion : this.filterExpansion),
|
|
993
1059
|
nodesVisited: 0,
|
|
994
1060
|
filterEvaluations: 0,
|
|
995
1061
|
}
|
|
@@ -1000,13 +1066,17 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1000
1066
|
let results: Candidate[] = [];
|
|
1001
1067
|
// For each level from top to bottom. The filter applies only at layer 0 (result admission);
|
|
1002
1068
|
// upper layers route unfiltered so non-matching hubs still guide the descent.
|
|
1069
|
+
//
|
|
1070
|
+
// Only layer 0 gets the full candidate list; the layers above it just hand down an entry point,
|
|
1071
|
+
// so searching them at the full ef costs work proportional to the layer's population (~N/M
|
|
1072
|
+
// nodes) rather than to ef. See DESIGN.md.
|
|
1003
1073
|
for (let l = entryPoint.level; l >= 0; l--) {
|
|
1004
1074
|
// Search for closest neighbors at current level
|
|
1005
1075
|
results = this.searchLayer(
|
|
1006
1076
|
target,
|
|
1007
1077
|
entryPointId,
|
|
1008
1078
|
entryPoint,
|
|
1009
|
-
effectiveEf,
|
|
1079
|
+
l === 0 ? effectiveEf : ROUTING_EF,
|
|
1010
1080
|
l,
|
|
1011
1081
|
options,
|
|
1012
1082
|
distanceFunction,
|
|
@@ -1040,10 +1110,8 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1040
1110
|
* and $distance.
|
|
1041
1111
|
*/
|
|
1042
1112
|
exactDistance(searchCondition: { target: number[]; distance?: string }, recordVector: number[] | Int8Array): number {
|
|
1043
|
-
if (
|
|
1044
|
-
|
|
1045
|
-
// float[] vector, but convert defensively in case a typed array slips through.
|
|
1046
|
-
const vec = Array.isArray(recordVector) ? recordVector : Array.from(recordVector);
|
|
1113
|
+
if (!searchCondition.target) throw new ClientError('A target vector must be provided for an HNSW query');
|
|
1114
|
+
if (!Array.isArray(searchCondition.target)) throw new ClientError('The target vector must be an array');
|
|
1047
1115
|
const fn =
|
|
1048
1116
|
searchCondition.distance === 'euclidean'
|
|
1049
1117
|
? euclideanDistance
|
|
@@ -1051,7 +1119,14 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1051
1119
|
? dotProductDistance
|
|
1052
1120
|
: searchCondition.distance === 'cosine'
|
|
1053
1121
|
? cosineDistance
|
|
1054
|
-
:
|
|
1122
|
+
: searchCondition.distance
|
|
1123
|
+
? null
|
|
1124
|
+
: this.distance;
|
|
1125
|
+
if (!fn) throw new ClientError('Unknown distance function');
|
|
1126
|
+
if (recordVector == null) return Infinity; // missing vector sorts last
|
|
1127
|
+
// distance fns require a plain Array (they guard on Array.isArray); records normally store a
|
|
1128
|
+
// float[] vector, but convert defensively in case a typed array slips through.
|
|
1129
|
+
const vec = Array.isArray(recordVector) ? recordVector : Array.from(recordVector);
|
|
1055
1130
|
return fn(searchCondition.target, vec);
|
|
1056
1131
|
}
|
|
1057
1132
|
/**
|
|
@@ -1223,9 +1298,11 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1223
1298
|
* @returns
|
|
1224
1299
|
*/
|
|
1225
1300
|
estimateCountAsSort() {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1301
|
+
// Same O(1) source search() uses — this runs per query whenever a vector sort is planned
|
|
1302
|
+
// alongside another condition, where an exact getKeysCount() is a second full key scan on the
|
|
1303
|
+
// query path. Scaled back to the index-store key count it used to be given, so the planner's
|
|
1304
|
+
// condition ordering is unchanged by the switch to a node count.
|
|
1305
|
+
return Math.sqrt(this.approximateNodeCount() * INDEX_KEYS_PER_NODE * this.efConstructionSearch);
|
|
1229
1306
|
}
|
|
1230
1307
|
|
|
1231
1308
|
/**
|
|
@@ -1234,27 +1311,24 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1234
1311
|
* @param vector
|
|
1235
1312
|
* @param context
|
|
1236
1313
|
* @param entry
|
|
1314
|
+
* @param sortDefinition
|
|
1237
1315
|
*/
|
|
1238
|
-
propertyResolver(vector: number[], context: any, entry: any) {
|
|
1239
|
-
const sortDefinition = context?.sort;
|
|
1316
|
+
propertyResolver(vector: number[], context: any, entry: any, sortDefinition?: any) {
|
|
1240
1317
|
if (sortDefinition) {
|
|
1318
|
+
if (!context) return this.exactDistance(sortDefinition, vector);
|
|
1241
1319
|
// set up a cache for these so they can be accessed by $distance and not be recalculated during a sort
|
|
1242
|
-
let
|
|
1243
|
-
if (
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1320
|
+
let vectorDistanceCaches = context.vectorDistanceCaches;
|
|
1321
|
+
if (!vectorDistanceCaches) vectorDistanceCaches = context.vectorDistanceCaches = new WeakMap();
|
|
1322
|
+
let vectorDistances = vectorDistanceCaches.get(sortDefinition);
|
|
1323
|
+
const cacheKey =
|
|
1324
|
+
entry && typeof entry === 'object' ? entry : vector && typeof vector === 'object' ? vector : null;
|
|
1325
|
+
if (vectorDistances && cacheKey) {
|
|
1326
|
+
const difference = vectorDistances.get(cacheKey);
|
|
1327
|
+
if (difference !== undefined) return difference;
|
|
1328
|
+
} else if (!vectorDistances) vectorDistanceCaches.set(sortDefinition, (vectorDistances = new WeakMap()));
|
|
1247
1329
|
|
|
1248
|
-
|
|
1249
|
-
if (
|
|
1250
|
-
distanceFunction =
|
|
1251
|
-
sortDefinition.distance === 'euclidean'
|
|
1252
|
-
? euclideanDistance
|
|
1253
|
-
: sortDefinition.distance === 'dotProduct'
|
|
1254
|
-
? dotProductDistance
|
|
1255
|
-
: cosineDistance;
|
|
1256
|
-
const distance = distanceFunction(sortDefinition.target, vector);
|
|
1257
|
-
vectorDistances.set(entry, distance);
|
|
1330
|
+
const distance = this.exactDistance(sortDefinition, vector);
|
|
1331
|
+
if (cacheKey) vectorDistances.set(cacheKey, distance);
|
|
1258
1332
|
return distance;
|
|
1259
1333
|
}
|
|
1260
1334
|
return vector;
|