@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,62 @@
|
|
|
1
|
+
// Why a lock that outlives the reader's budget is retried from a timer, and why both the bound and
|
|
2
|
+
// the backoff are wall clock rather than an attempt count: see "Root config watchers must read
|
|
3
|
+
// synchronously" in DESIGN.md (harper#2191).
|
|
4
|
+
const RETRY_BUDGET_MS = 3_100;
|
|
5
|
+
const INITIAL_DELAY_MS = 100;
|
|
6
|
+
const MAX_DELAY_MS = 1_600;
|
|
7
|
+
|
|
8
|
+
let retryBudgetMs = RETRY_BUDGET_MS;
|
|
9
|
+
|
|
10
|
+
// Test-only, the read-side twin of `atomicWriteFile`'s `retryBudgetMs` option. A case that only
|
|
11
|
+
// needs the ladder *spent* has no way to get there but wall clock, so without this each one sits
|
|
12
|
+
// out the shipped 3.1 s; a dozen of them cost the unit job 40 s. Called with no argument it
|
|
13
|
+
// restores the shipped value, and `configReadRetry.test.js` still spends that value in full, so
|
|
14
|
+
// shortening it elsewhere cannot hide a change to what ships.
|
|
15
|
+
export function _setRetryBudgetForTests(ms: number = RETRY_BUDGET_MS): void {
|
|
16
|
+
retryBudgetMs = ms;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConfigReadRetry {
|
|
20
|
+
#timer?: NodeJS.Timeout;
|
|
21
|
+
#deadline?: number;
|
|
22
|
+
// Captured with the deadline: the backoff is a fraction of the budget the deadline came from,
|
|
23
|
+
// so reading the module value again mid-ladder would mis-scale it if a test had changed it.
|
|
24
|
+
#budgetMs: number = retryBudgetMs;
|
|
25
|
+
|
|
26
|
+
// `holdEventLoop` is for a caller whose boot barrier this ladder is the only thing left to
|
|
27
|
+
// settle: an unref'd timer would let the thread drain and exit mid-boot instead.
|
|
28
|
+
schedule(retry: () => void, holdEventLoop: boolean = false): boolean {
|
|
29
|
+
this.cancel();
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
if (this.#deadline === undefined) {
|
|
32
|
+
this.#budgetMs = retryBudgetMs;
|
|
33
|
+
this.#deadline = now + this.#budgetMs;
|
|
34
|
+
}
|
|
35
|
+
const remainingMs = this.#deadline - now;
|
|
36
|
+
if (remainingMs <= 0) {
|
|
37
|
+
this.reset();
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const elapsedMs = this.#budgetMs - remainingMs;
|
|
41
|
+
const delayMs = Math.min(Math.max(elapsedMs, INITIAL_DELAY_MS), MAX_DELAY_MS, remainingMs);
|
|
42
|
+
this.#timer = setTimeout(retry, delayMs);
|
|
43
|
+
if (!holdEventLoop) this.#timer.unref();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get pending(): boolean {
|
|
48
|
+
return this.#timer !== undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
reset(): void {
|
|
52
|
+
this.cancel();
|
|
53
|
+
this.#deadline = undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cancel(): void {
|
|
57
|
+
if (this.#timer) {
|
|
58
|
+
clearTimeout(this.#timer);
|
|
59
|
+
this.#timer = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/config/configUtils.ts
CHANGED
|
@@ -92,22 +92,56 @@ export function getConfigPath(param: string) {
|
|
|
92
92
|
return path.resolve(rootPath, value);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Write atomically via temp file + rename so readers
|
|
96
|
-
//
|
|
97
|
-
// in the same millisecond can't collide on the temp name and then race the rename.
|
|
98
|
-
//
|
|
95
|
+
// Write atomically via a randomized temp file + rename so readers do not observe partial content
|
|
96
|
+
// and concurrent workers, which share process.pid, do not collide on a temp path.
|
|
99
97
|
// Windows has no POSIX-style "replace an open file" semantics: rename() fails with
|
|
100
|
-
// EPERM/EACCES while another
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
98
|
+
// EPERM/EACCES/EBUSY while another worker or AV holds the destination open. Root config watchers use
|
|
99
|
+
// readConfigFileSync so this blocking retry cannot wait on a read owned by its own worker.
|
|
100
|
+
// The budget is the wall-clock window of the 12-attempt schedule it replaced
|
|
101
|
+
// (10+20+40+80+160+320+500*6), so it stays a deadline rather than an attempt count without
|
|
102
|
+
// widening the stall: this loop blocks the calling worker's event loop, and `set_configuration`
|
|
103
|
+
// reaches it from a live request thread.
|
|
104
|
+
const RENAME_RETRY_BUDGET_MS = 3_630;
|
|
105
|
+
// Secondary guard only: stops a degenerate zero-delay option set from spinning the whole budget.
|
|
106
|
+
const RENAME_RETRY_MAX_ATTEMPTS = 25;
|
|
106
107
|
const RENAME_RETRY_INITIAL_DELAY_MS = 10;
|
|
107
108
|
const RENAME_RETRY_MAX_DELAY_MS = 500;
|
|
108
|
-
// Never notified;
|
|
109
|
+
// Never notified; Atomics.wait uses this only as a CPU-idle synchronous sleep.
|
|
109
110
|
const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
110
111
|
|
|
112
|
+
// Classified by code alone rather than gated to win32 like the read side: `process.platform` does
|
|
113
|
+
// not answer whether this filesystem can replace an open file — WSL drvfs, CIFS/SMB and Docker
|
|
114
|
+
// Desktop bind mounts all report `linux` and return these codes transiently.
|
|
115
|
+
function isRetryableRenameError(code: string): boolean {
|
|
116
|
+
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type RenameRetryOptions = {
|
|
120
|
+
retryBudgetMs?: number;
|
|
121
|
+
maxRetries?: number;
|
|
122
|
+
initialDelayMs?: number;
|
|
123
|
+
maxDelayMs?: number;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type AtomicWriteOptions = RenameRetryOptions & {
|
|
127
|
+
skipIfUnchanged?: boolean;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs }: RenameRetryOptions) {
|
|
131
|
+
const invalidOption =
|
|
132
|
+
!Number.isFinite(retryBudgetMs) ||
|
|
133
|
+
retryBudgetMs < 0 ||
|
|
134
|
+
(!Number.isFinite(maxRetries) && maxRetries !== Infinity) ||
|
|
135
|
+
maxRetries < 0 ||
|
|
136
|
+
!Number.isFinite(initialDelayMs) ||
|
|
137
|
+
initialDelayMs < 0 ||
|
|
138
|
+
!Number.isFinite(maxDelayMs) ||
|
|
139
|
+
maxDelayMs < 0;
|
|
140
|
+
if (invalidOption) {
|
|
141
|
+
throw new RangeError('rename retry options must be non-negative numbers');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
// Linux has no libuv mapping for EDQUOT, so a quota-exhausted write surfaces as
|
|
112
146
|
// `Unknown system error -122` with an unusable `code`; the numeric errno is the portable signal
|
|
113
147
|
// (EDQUOT is 122 on Linux, 69 on macOS).
|
|
@@ -142,12 +176,15 @@ export function atomicWriteFile(
|
|
|
142
176
|
filePath,
|
|
143
177
|
content,
|
|
144
178
|
{
|
|
179
|
+
retryBudgetMs = RENAME_RETRY_BUDGET_MS,
|
|
145
180
|
maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
|
|
146
181
|
initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
|
|
147
182
|
maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
|
|
148
183
|
skipIfUnchanged = false,
|
|
149
|
-
} = {}
|
|
184
|
+
}: AtomicWriteOptions = {}
|
|
150
185
|
) {
|
|
186
|
+
// Before the temp write, so an option set that can never rename leaves no file behind.
|
|
187
|
+
validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
|
|
151
188
|
// Opt-in: skipping means no mtime bump, so no watcher event. Only callers that re-derive the
|
|
152
189
|
// same file every boot want that.
|
|
153
190
|
if (skipIfUnchanged && matchesFileContent(filePath, content)) return false;
|
|
@@ -160,8 +197,10 @@ export function atomicWriteFile(
|
|
|
160
197
|
throw err;
|
|
161
198
|
}
|
|
162
199
|
try {
|
|
163
|
-
renameWithRetry(tempPath, filePath, { maxRetries, initialDelayMs, maxDelayMs });
|
|
200
|
+
renameWithRetry(tempPath, filePath, { retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
|
|
164
201
|
} catch (err) {
|
|
202
|
+
// The temp name carries fresh randomness on every call, so a spent budget would otherwise
|
|
203
|
+
// leave a file nothing else will ever collect.
|
|
165
204
|
removeTempFile(tempPath);
|
|
166
205
|
throw err;
|
|
167
206
|
}
|
|
@@ -172,35 +211,48 @@ export function renameWithRetry(
|
|
|
172
211
|
fromPath,
|
|
173
212
|
toPath,
|
|
174
213
|
{
|
|
214
|
+
retryBudgetMs = RENAME_RETRY_BUDGET_MS,
|
|
175
215
|
maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
|
|
176
216
|
initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
|
|
177
217
|
maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
|
|
178
|
-
} = {}
|
|
218
|
+
}: RenameRetryOptions = {}
|
|
179
219
|
) {
|
|
220
|
+
validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
|
|
180
221
|
let retries = maxRetries;
|
|
181
222
|
let delayMs = initialDelayMs;
|
|
223
|
+
let retryDeadline;
|
|
224
|
+
let finalAttempt = false;
|
|
182
225
|
let attempts = 0;
|
|
183
|
-
const startedAt =
|
|
226
|
+
const startedAt = performance.now();
|
|
184
227
|
while (true) {
|
|
185
228
|
try {
|
|
186
229
|
attempts++;
|
|
187
230
|
fs.renameSync(fromPath, toPath);
|
|
188
231
|
return;
|
|
189
232
|
} catch (err) {
|
|
190
|
-
if (retries > 0 && (err.code
|
|
233
|
+
if (!finalAttempt && retries > 0 && isRetryableRenameError(err.code)) {
|
|
191
234
|
retries--;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
235
|
+
if (retryDeadline === undefined) {
|
|
236
|
+
retryDeadline = performance.now() + retryBudgetMs;
|
|
237
|
+
}
|
|
238
|
+
const remainingBudgetMs = retryDeadline - performance.now();
|
|
239
|
+
if (remainingBudgetMs > 0) {
|
|
240
|
+
// Sleep synchronously (all call sites are sync) to allow the holder to close the
|
|
241
|
+
// file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
|
|
242
|
+
// which is what makes a multi-second worst-case budget affordable.
|
|
243
|
+
const sleepMs = Math.min(delayMs, remainingBudgetMs);
|
|
244
|
+
finalAttempt = sleepMs === remainingBudgetMs;
|
|
245
|
+
if (sleepMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, sleepMs);
|
|
246
|
+
delayMs = Math.min(Math.max(delayMs * 2, RENAME_RETRY_INITIAL_DELAY_MS), maxDelayMs);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
198
249
|
}
|
|
199
|
-
//
|
|
200
|
-
// a
|
|
201
|
-
|
|
250
|
+
// Whether the budget was spent or the code was never retryable is the difference
|
|
251
|
+
// between a holder that never released and a one-off failure, and neither survives on
|
|
252
|
+
// the rethrown error.
|
|
253
|
+
if (isRetryableRenameError(err.code)) {
|
|
202
254
|
logger.warn(
|
|
203
|
-
`Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${
|
|
255
|
+
`Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${Math.round(performance.now() - startedAt)}ms`
|
|
204
256
|
);
|
|
205
257
|
}
|
|
206
258
|
throw err;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
|
|
3
|
+
// yaml's prettyErrors frames the offending source lines into `message`, and a config file holds
|
|
4
|
+
// credentials. Neither the cause nor the original stack is carried for the same reason.
|
|
5
|
+
export class ConfigParseError extends Error {
|
|
6
|
+
constructor(filePath: string, error: unknown) {
|
|
7
|
+
const { name, code, linePos } = (error ?? {}) as {
|
|
8
|
+
name?: string;
|
|
9
|
+
code?: string;
|
|
10
|
+
linePos?: { line: number; col: number }[];
|
|
11
|
+
};
|
|
12
|
+
const at = linePos?.[0] ? ` at line ${linePos[0].line}, column ${linePos[0].col}` : '';
|
|
13
|
+
super(`Unable to parse the Harper configuration file at ${filePath}: ${code ?? name ?? 'parse failure'}${at}`);
|
|
14
|
+
this.name = 'ConfigParseError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseConfigFile(contents: string, filePath: string): any {
|
|
19
|
+
try {
|
|
20
|
+
// yaml routes warnings through `process.emitWarning` rather than a throw, so a framed
|
|
21
|
+
// warning would reach stderr with the config's own source lines in it, around this scrub.
|
|
22
|
+
return parse(contents, { logLevel: 'error' });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// Only yaml's own parse errors frame the source into `message`. Anything else is a fault in
|
|
25
|
+
// the parser, where the message is the whole of the debugging context.
|
|
26
|
+
if (!isYamlParseError(error)) throw error;
|
|
27
|
+
throw new ConfigParseError(filePath, error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isYamlParseError(error: unknown): boolean {
|
|
32
|
+
const { name, linePos } = (error ?? {}) as { name?: string; linePos?: unknown };
|
|
33
|
+
return linePos !== undefined || name === 'YAMLParseError';
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Why root config reads must not be async, and why this budget is shared rather than per call:
|
|
4
|
+
// see "Root config watchers must read synchronously" in DESIGN.md (harper#2191).
|
|
5
|
+
const READ_RETRY_BUDGET_MS = 500;
|
|
6
|
+
const READ_RETRY_INITIAL_DELAY_MS = 10;
|
|
7
|
+
const READ_RETRY_MAX_DELAY_MS = 100;
|
|
8
|
+
const readRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
9
|
+
const retryDeadlines = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
// A caller that already owns a retry ladder passes `waitForLock` false, so one lock costs one
|
|
12
|
+
// blocking window in total rather than one per rung.
|
|
13
|
+
export function readConfigFileSync(filePath: string, waitForLock: boolean = true): string {
|
|
14
|
+
let delayMs = READ_RETRY_INITIAL_DELAY_MS;
|
|
15
|
+
while (true) {
|
|
16
|
+
try {
|
|
17
|
+
const contents = readFileSync(filePath, 'utf-8');
|
|
18
|
+
retryDeadlines.delete(filePath);
|
|
19
|
+
return contents;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const remainingBudgetMs = waitForLock && isSharingViolation(error) ? remainingRetryBudgetMs(filePath) : 0;
|
|
22
|
+
if (remainingBudgetMs <= 0) throw error;
|
|
23
|
+
Atomics.wait(readRetrySleepBuffer, 0, 0, Math.min(delayMs, remainingBudgetMs));
|
|
24
|
+
delayMs = Math.min(delayMs * 2, READ_RETRY_MAX_DELAY_MS);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isSharingViolation(error: unknown): boolean {
|
|
30
|
+
if (process.platform !== 'win32') return false;
|
|
31
|
+
const code = (error as { code?: string } | null)?.code;
|
|
32
|
+
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function remainingRetryBudgetMs(filePath: string): number {
|
|
36
|
+
const now = performance.now();
|
|
37
|
+
let deadline = retryDeadlines.get(filePath);
|
|
38
|
+
// A deadline more than one budget past its expiry belongs to an earlier burst, not this one.
|
|
39
|
+
if (deadline === undefined || now - deadline > READ_RETRY_BUDGET_MS) {
|
|
40
|
+
deadline = now + READ_RETRY_BUDGET_MS;
|
|
41
|
+
retryDeadlines.set(filePath, deadline);
|
|
42
|
+
}
|
|
43
|
+
return deadline - now;
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// See DESIGN.md, "`ready` means the watcher is armed". Platform is a parameter so a test on any
|
|
2
|
+
// host can pin the darwin value, rather than only observing its own.
|
|
3
|
+
export function armGraceMs(platform: string = process.platform): number {
|
|
4
|
+
return platform === 'darwin' ? 20 : 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ARM_GRACE_MS = armGraceMs();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The gate a config watcher opens when its chokidar watcher is really watching: the initial scan
|
|
11
|
+
* has finished *and* the platform's kernel-side warm-up has had its grace. Shared by
|
|
12
|
+
* `RootConfigWatcher` and `OptionsWatcher` because both read the config synchronously, which is
|
|
13
|
+
* what exposes the unarmed window in the first place.
|
|
14
|
+
*/
|
|
15
|
+
export class ArmGate {
|
|
16
|
+
#armed: boolean = false;
|
|
17
|
+
#timer: NodeJS.Timeout | undefined;
|
|
18
|
+
#graceMs: number;
|
|
19
|
+
|
|
20
|
+
// Taken as an argument, not read from the module constant, so the timer branch below is
|
|
21
|
+
// reachable from a host whose own platform has no grace — unit tests are ubuntu-only.
|
|
22
|
+
constructor(graceMs: number = ARM_GRACE_MS) {
|
|
23
|
+
this.#graceMs = graceMs;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get armed(): boolean {
|
|
27
|
+
return this.#armed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Synchronous where the platform needs no grace, so a watcher with no warm-up to wait out
|
|
31
|
+
// keeps reading inside chokidar's own dispatch.
|
|
32
|
+
arm(onArmed: () => void): void {
|
|
33
|
+
if (this.#armed || this.#timer) return;
|
|
34
|
+
if (!this.#graceMs) {
|
|
35
|
+
this.#armed = true;
|
|
36
|
+
onArmed();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.#timer = setTimeout(() => {
|
|
40
|
+
this.#timer = undefined;
|
|
41
|
+
this.#armed = true;
|
|
42
|
+
onArmed();
|
|
43
|
+
}, this.#graceMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Drops a grace still counting down, for a watcher that is closing.
|
|
47
|
+
cancel(): void {
|
|
48
|
+
clearTimeout(this.#timer);
|
|
49
|
+
this.#timer = undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A replacement watcher has its own scan and its own unarmed window, so the gate arms again
|
|
53
|
+
// with it: for a file that is *absent* when the replacement scans, chokidar reports `ready` and
|
|
54
|
+
// nothing else, and the arming re-read is the only thing that would notice.
|
|
55
|
+
reset(): void {
|
|
56
|
+
this.cancel();
|
|
57
|
+
this.#armed = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/config-root.schema.json
CHANGED
|
@@ -172,6 +172,10 @@
|
|
|
172
172
|
]
|
|
173
173
|
},
|
|
174
174
|
"maxHeapMemory": { "type": "number", "description": "Heap memory limit per thread (MB)." },
|
|
175
|
+
"maxIsolated": {
|
|
176
|
+
"type": "integer",
|
|
177
|
+
"description": "Most applications marked isolated that get a dedicated worker thread; the rest fail to load. Default 8."
|
|
178
|
+
},
|
|
175
179
|
"heapSnapshotNearLimit": { "type": "boolean", "description": "Take a heap snapshot when near the heap limit." },
|
|
176
180
|
"preload": {
|
|
177
181
|
"oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# dataLayer/ — Design notes
|
|
2
|
+
|
|
3
|
+
Backup and restore, version gating, system-table bootstrap and storage migration.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `rocksdbBackup.ts`, `restoreMarker.ts`, `blobBackup.ts`, `hdbInfoController.ts`, `bin/copyDb.ts` or `json/systemSchema.json`.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
|
|
12
|
+
|
|
13
|
+
`getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see the struct-mode section above and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
|
|
14
|
+
|
|
15
|
+
- The prompt's answer can be supplied non-interactively via `CONFIRM_DOWNGRADE` — env var or `--CONFIRM_DOWNGRADE` CLI arg; argv wins (`assignCMDENVVariables`). With no override and no TTY on stdin, the prompt throws instead of blocking on stdin forever (#2046 — services/CI hung with nothing in the log; the mismatch is also logged to hdb.log now).
|
|
16
|
+
- Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` on the 4.x upgrade path still has the block-on-stdin hazard.
|
|
17
|
+
- Test-suite gotcha: a suite that supplies the override via `process.argv` affects every later test file in the same mocha process — save and restore `process.argv` in `before`/`after` (see `unitTests/dataLayer/hdbInfoController.test.js`).
|
|
18
|
+
|
|
19
|
+
## Opening a source LMDB DBI for migration must thread through `compression`
|
|
20
|
+
|
|
21
|
+
When `migrateOnStart` opens a source LMDB primary store to read records out for the RocksDB copy, it constructs an `OpenDBIObject` and calls `sourceRootStore.openDB(key, dbiInit)`. Critically, the per-attribute `compression` setting from the corresponding `__dbis__` entry must be assigned onto `dbiInit` before that call — `dbiInit.compression = attribute.compression`. Without it, lmdb-js doesn't install its decompression layer; every read on the DBI returns raw compressed bytes. msgpackr then misreads bytes in the `0x40–0x7F` range as shared-structure refs, calls `loadStructures` → decodes the (also compressed) structures buffer → finds more bytes in that range → recurses → stack overflow.
|
|
22
|
+
|
|
23
|
+
Harper's normal `databases.ts` path already does this (search for `dbiInit.compression = primaryKeyAttribute.compression`); the migration path in `bin/copyDb.ts` has to match.
|
|
24
|
+
|
|
25
|
+
The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "use the build default (lz4)" — the inverse default of lmdb. Every RocksDB open must therefore route through `toRocksCompression()` in `resources/databases.ts` (applied inside `openRocksDatabase`, the single chokepoint), which maps defined-falsy → `'none'` and enabled-without-an-algorithm → an explicit lz4 request when available. Don't pass persisted attribute compression to a RocksDB open directly.
|
|
26
|
+
|
|
27
|
+
`bin/copyDb.ts`'s `openRocksDb` is part of that chokepoint, not an exception to it. This is about the bytes migration writes, not about a later failure: `copyDbToRocks()` closes every target handle before the staging directory is renamed, and rocksdb-js permits an explicit codec change across a close/reopen, so the runtime would open the migrated database fine either way. But a migration that ignores the configured codec writes the entire dataset uncompressed, and those SST/blob files then keep their original codec until write traffic rewrites them — a full LMDB→RocksDB migration is the one moment the whole dataset is written at once, so it is exactly when the deployment's codec should apply.
|
|
28
|
+
|
|
29
|
+
## System table bootstrap: `systemSchema.json` + upgrade directive
|
|
30
|
+
|
|
31
|
+
Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three changes:
|
|
32
|
+
|
|
33
|
+
1. **`json/systemSchema.json`** — the table entry. Fresh installs auto-create it via `utility/mount_hdb.ts:createTables()`, which iterates `Object.keys(systemSchema)` on first boot.
|
|
34
|
+
2. **`utility/hdbTerms.ts`** — add the table name to `SYSTEM_TABLE_NAMES`.
|
|
35
|
+
3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports). The directive shape is `{ version, sync_functions, async_functions }`; copy `5-1-0.ts` for the canonical pattern (uses `bridge.createTable` to match what `mount_hdb` does on a fresh install).
|
|
36
|
+
|
|
37
|
+
**Version the directive to the first release that ships the dependent code, not a later one.** Directives only run when `current_version < directive_version <= upgrade_version` (`directivesController.getVersionsForUpgrade`). The `hdb_deployment` directive was originally mis-tagged `5.2.0` while the deployment-recorder code shipped in `5.1.0`, so on every `5.0.x -> 5.1.x` upgrade the directive was filtered out (`5.2.0 > 5.1.x`) and the table never got created — breaking replicated `deploy_component` on peer nodes for the entire existing customer base. Caveat: `utility/common_utils.ts:compareVersions` strips trailing `.0` and therefore sorts a pre-release (`5.1.0-beta.1`) _above_ its GA (`5.1.0`), so an install already on a `5.1.0-beta.x` data version will not pick up a `5.1.0` directive when upgrading to GA; those pre-release installs need the table created by other means.
|
|
38
|
+
|
|
39
|
+
System tables replicate by default. To opt out, add the name to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The check happens after table init and sets `table.replicate = false` per-node.
|
|
40
|
+
|
|
41
|
+
If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
|
|
42
|
+
|
|
43
|
+
## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
|
|
44
|
+
|
|
45
|
+
The `restore_backup` operation restores a user database on a live server by closing it across all
|
|
46
|
+
worker threads, purging its directory (`backups.restore` with `purgeAllFiles`), and reloading it.
|
|
47
|
+
Three non-obvious mechanics keep that safe:
|
|
48
|
+
|
|
49
|
+
- **Two files in an isolated `` `restore` `` directory beside (never inside) the database directory**,
|
|
50
|
+
each keyed by `sha256(basename(dbPath)).slice(0,32)`: `<key>.lock`, an OS-level exclusive flock
|
|
51
|
+
(rocksdb-js `tryFileLock`, auto-released on process death), serializes restores; `<key>.restoring`,
|
|
52
|
+
a marker written+fsynced (file _and_ the metadata directory) after the lock and before any
|
|
53
|
+
destructive step, means "a restore started and has not finished" (its first line records the
|
|
54
|
+
database directory name so the scan can map a marker back without decoding the key). The metadata
|
|
55
|
+
is hashed into a sibling directory rather than suffixed onto the database name (`<db>.restoring`)
|
|
56
|
+
for two reasons: a legal database literally named `orders.restoring` would otherwise be mistaken
|
|
57
|
+
for the restore marker of `orders`, and a 250-character name (the legal max) plus a `.restore.lock`
|
|
58
|
+
suffix exceeds `NAME_MAX` (255) on most filesystems. The directory name deliberately contains a
|
|
59
|
+
backtick — `schemaRegex` (the database-name validator) forbids only `/` and a backtick among
|
|
60
|
+
filesystem-legal characters — so it can never collide with a legal database name, including a
|
|
61
|
+
database literally named `.restore` (which _is_ a legal name; a plain `.restore/` directory would
|
|
62
|
+
be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
|
|
63
|
+
directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
|
|
64
|
+
entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
|
|
65
|
+
detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
|
|
66
|
+
reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
|
|
67
|
+
probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
|
|
68
|
+
file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
|
|
69
|
+
healthy databases as in-progress. Marker-present + lock-held = restore in progress (don't load);
|
|
70
|
+
marker-present + lock-free = crashed mid-restore (don't load; rerun the restore to recover).
|
|
71
|
+
- **A recovery restore must not clear a pre-existing marker on a pre-destruction failure.**
|
|
72
|
+
`beginRestore` returns `preexisting: true` when a `.restoring` marker was already present (this run
|
|
73
|
+
is a recovery over a possibly half-purged directory). If such a run fails _before_ any destruction
|
|
74
|
+
(e.g. `verifyDatabaseClosed` finds a leaked handle), it must leave the marker in place — clearing
|
|
75
|
+
it and broadcasting a reload would surface the earlier attempt's partial/corrupt directory as
|
|
76
|
+
healthy. Only a _fresh_ marker on a _previously healthy_ database that failed before destruction is
|
|
77
|
+
safe to clear.
|
|
78
|
+
- **The ITC close broadcast is best-effort, so closure is verified before the purge.** The SCHEMA
|
|
79
|
+
broadcast (`signalSchemaChange`) resolves after remote handlers complete but times out at 30s
|
|
80
|
+
"best-effort", swallows errors, and never reaches job-worker threads at all (their ports are
|
|
81
|
+
excluded from broadcasts to avoid re-entrant deadlocks). A destructive purge cannot trust it:
|
|
82
|
+
`restoreBackup` polls rocksdb-js `registryStatus()` (process-global across worker threads) until
|
|
83
|
+
the database path has no open instance, and aborts with a 409 — _cleaning up the marker, since
|
|
84
|
+
nothing was destroyed_ — if handles remain.
|
|
85
|
+
- **Online restore is impossible for a database a component holds open — and that failure is
|
|
86
|
+
correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
|
|
87
|
+
attribution to a thread or component; Harper keeps no component→database ownership map. So when a
|
|
88
|
+
loaded component (or the `system` database, which Harper itself never stops while running) holds
|
|
89
|
+
its own handle on the target database, `registryStatus()` stays non-zero, Harper can neither
|
|
90
|
+
identify nor force-close that handle, and an in-place purge would corrupt a live instance.
|
|
91
|
+
`verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
|
|
92
|
+
just-finished job worker's own close to drain) and then fails fast with a 409 that points at
|
|
93
|
+
running the operation offline (`harper restore_backup` with the server stopped, where no
|
|
94
|
+
components are loaded and nothing holds the database open). Offline restore is the supported path
|
|
95
|
+
for component-held and `system` databases; online restore serves databases not actively held by a
|
|
96
|
+
component. The CLI exposes each backup operation under its operation name only (`create_backup`,
|
|
97
|
+
`restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
|
|
98
|
+
or, when the local server is stopped, to the equivalent offline function.
|
|
99
|
+
- **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
|
|
100
|
+
rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
|
|
101
|
+
closing leaks its handles (the refCount never drops); the only alternative, `shutdown()`, tears
|
|
102
|
+
down rocksdb for the _entire_ process. A job worker (`server/jobs/jobProcess.ts`) opens the whole
|
|
103
|
+
database graph via `getDatabases()` and exits when the job finishes — and `create_backup` is
|
|
104
|
+
itself a job, so before any `restore_backup` there is always at least one exited job worker that
|
|
105
|
+
touched the database. Without cleanup those leaked handles keep `registryStatus()` non-zero and
|
|
106
|
+
would fail the closure check even when no component holds the database. `jobProcess` therefore
|
|
107
|
+
calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
|
|
108
|
+
user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
|
|
109
|
+
exited job worker leaves no residual handle to be mistaken for a live holder.
|
|
110
|
+
- **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
|
|
111
|
+
A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
|
|
112
|
+
a "successful" restore (or vice versa). `dropDatabase` therefore _acquires_ the restore lock
|
|
113
|
+
(`acquireRestoreLock`, marker-less) for each RocksDB root store and holds it across the whole drop,
|
|
114
|
+
releasing in a `finally`; a restore in progress makes the acquire fail with 409, and a leftover
|
|
115
|
+
incomplete-restore marker (lock free, detected via `restoreMarkerPresent`, which — unlike
|
|
116
|
+
`checkRestoreState` — is safe while this thread holds the lock) is refused rather than dropped over.
|
|
117
|
+
`database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
|
|
118
|
+
`create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
|
|
119
|
+
the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
|
|
120
|
+
- **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
|
|
121
|
+
only when the CLI sees no server (a PID heuristic; the PID file is briefly absent mid-`harper
|
|
122
|
+
restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so before purging,
|
|
123
|
+
`restoreBackupOffline` opens the database to probe. It now takes the restore lock+marker _before_
|
|
124
|
+
probing (so a server that starts afterward sees the marker and refuses to load), and recognizes the
|
|
125
|
+
pinned rocksdb-js 2.5.0 lock error — a plain `Error` with no `code` and message
|
|
126
|
+
`IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` (`isRocksDbLockError`) —
|
|
127
|
+
aborting with a 409 rather than purging a database another process holds open. Any _other_ open
|
|
128
|
+
failure (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
|
|
129
|
+
|
|
130
|
+
Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
|
|
131
|
+
the process exiting, the lock stays held (restores 409) until Harper restarts. There is no typed
|
|
132
|
+
native lock signal in rocksdb-js 2.5.0, so the offline probe relies on message matching; a native
|
|
133
|
+
lock primitive is a rocksdb-js follow-on.
|
|
134
|
+
|
|
135
|
+
## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
|
|
136
|
+
|
|
137
|
+
A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
|
|
138
|
+
(`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
|
|
139
|
+
`<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
|
|
140
|
+
`restore_backup`, `delete_backup`, `purge_backups`, and the streaming `get_backup` therefore handle
|
|
141
|
+
blobs alongside the engine data (the `exclude_blobs` request option — default false — opts out for an
|
|
142
|
+
engine-only backup):
|
|
143
|
+
|
|
144
|
+
- **Managed backups** snapshot the blob roots to `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`
|
|
145
|
+
— a full, non-incremental copy per backup, mirroring the binding's `transaction_logs/<id>/` layout.
|
|
146
|
+
Each enumerated entry is classified before capture: complete blobs and existing abort markers are
|
|
147
|
+
hard-linked when possible (copied across filesystems), `.repair` temporaries are omitted, and an
|
|
148
|
+
incomplete blob is replaced by a retryable PENDING (`0xfe`) marker. If a classified blob vanishes
|
|
149
|
+
before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
|
|
150
|
+
parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
|
|
151
|
+
live write finishes, while complete blobs remain safe to hard-link because published blob paths are
|
|
152
|
+
write-once. The snapshot is built in a `.tmp-<id>` sibling and atomically renamed so a failed create
|
|
153
|
+
leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
|
|
154
|
+
`delete_backup` / `purge_backups` remove the corresponding snapshot directories.
|
|
155
|
+
- **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
|
|
156
|
+
binding's streaming backup finalizes its tar with exactly a 1024-byte (two-block) end-of-archive
|
|
157
|
+
marker; `createBackupStream` streams the native _plain_ tar while withholding that trailer
|
|
158
|
+
(verifying it is all-zero), appends the blob entries via `tar-stream` (whose `finalize` writes the
|
|
159
|
+
one real trailer), and gzips the combined stream itself when requested — so the binding is always
|
|
160
|
+
asked for a plain tar and compression happens after the append. No scratch disk. The same blob
|
|
161
|
+
classification rule applies: complete blobs are streamed, incomplete or post-enumeration missing
|
|
162
|
+
blobs become PENDING/ERROR marker entries, and repair temporaries are omitted.
|
|
163
|
+
|
|
164
|
+
**Completion manifest (`dataLayer/backupManifest.ts`).** `create_backup` is two-phase: the engine
|
|
165
|
+
backup (`rootStore.backup()`) resolves — and is immediately visible to `list_backups`/`verify_backup`/
|
|
166
|
+
`restore_backup` — before the blob snapshot is copied. Without a completion record, a blob-snapshot
|
|
167
|
+
failure (or a crash between the phases) would leave an engine backup that lists and verifies as
|
|
168
|
+
healthy while silently missing its blobs, and a concurrent restore could pick a backup id whose
|
|
169
|
+
snapshot is still being written and treat it as intentionally engine-only. So a manifest at
|
|
170
|
+
`<backupDir>/manifests/<backupId>.json` — recording the blob-inclusion policy — is written
|
|
171
|
+
(atomically, temp + rename) only after _both_ phases are durable, and a graceful blob-snapshot
|
|
172
|
+
failure rolls back the just-created engine backup + partial snapshot. Consumers treat a backup id
|
|
173
|
+
with no manifest as incomplete: `list_backups` hides it, `verify_backup`/`restore_backup` reject it
|
|
174
|
+
(409 for a specific id, "no complete backups" for `latest`), and restore uses the manifest's `blobs`
|
|
175
|
+
flag — not the mere presence of a snapshot dir — to decide whether to restore blobs (so an engine-only
|
|
176
|
+
backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
|
|
177
|
+
corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
|
|
178
|
+
the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
|
|
179
|
+
walk) is the documented best-effort limitation above.
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
import { collapseData } from '../../resources/tracked.ts';
|
|
26
26
|
import { errorToString } from '../../utility/logging/harper_logger.ts';
|
|
27
27
|
import { RocksDatabase } from '@harperfast/rocksdb-js';
|
|
28
|
+
import { boundedAuditPruneEnd, raiseAuditFloor } from '../../resources/auditStore.ts';
|
|
28
29
|
import { BridgeMethods } from './BridgeMethods.ts';
|
|
29
30
|
import lmdbGetBackup from './lmdbBridge/lmdbMethods/lmdbGetBackup.js';
|
|
30
31
|
import { createBackupStream, resolveSingleRootStore } from '../rocksdbBackup.ts';
|
|
@@ -520,8 +521,26 @@ export class ResourceBridge extends BridgeMethods {
|
|
|
520
521
|
deleteObj.timestamp instanceof Date
|
|
521
522
|
? deleteObj.timestamp.getTime()
|
|
522
523
|
: typeof deleteObj.timestamp === 'string'
|
|
523
|
-
? Number.parseInt(
|
|
524
|
+
? // Number, not Number.parseInt: parseInt takes a numeric PREFIX ('9999999999999oops' is a
|
|
525
|
+
// year-2286 bound to it, '1e3' is 1) where Number rejects both as NaN. Empty/whitespace is
|
|
526
|
+
// explicit because Number('') and Number(' ') are 0.
|
|
527
|
+
deleteObj.timestamp.trim() === ''
|
|
528
|
+
? Number.NaN
|
|
529
|
+
: Number(deleteObj.timestamp)
|
|
524
530
|
: deleteObj.timestamp;
|
|
531
|
+
// Audit keys are raw float64: NaN and negatives sort above every real timestamp (a range ending
|
|
532
|
+
// there spans the whole log), and Infinity records the unknown sentinel raiseAuditFloor never lifts.
|
|
533
|
+
// Reject anything but a finite non-negative number here, as the 400 it is rather than a server
|
|
534
|
+
// fault. `Number.isFinite` never coerces, so it is the type check too.
|
|
535
|
+
if (!Number.isFinite(before) || before < 0 || Object.is(before, -0))
|
|
536
|
+
throw handleHDBError(
|
|
537
|
+
new Error(),
|
|
538
|
+
`'timestamp' must be a non-negative epoch time or Date, received: ${String(deleteObj.timestamp)}`,
|
|
539
|
+
400,
|
|
540
|
+
undefined,
|
|
541
|
+
undefined,
|
|
542
|
+
true
|
|
543
|
+
);
|
|
525
544
|
const databaseName = deleteObj.database || deleteObj.schema || DEFAULT_DATABASE;
|
|
526
545
|
const table = getTable(deleteObj);
|
|
527
546
|
// A nonexistent table must not fall through to the no-table branch below — on RocksDB that
|
|
@@ -544,7 +563,14 @@ export class ResourceBridge extends BridgeMethods {
|
|
|
544
563
|
if (tables) {
|
|
545
564
|
for (const table of Object.values(tables)) {
|
|
546
565
|
if (table.primaryStore instanceof RocksDatabase) {
|
|
547
|
-
|
|
566
|
+
// Clamp before recording: an operator-supplied bound has no ceiling of its own, and a
|
|
567
|
+
// floor above everything reachable never comes down (`raiseAuditFloor` only raises and
|
|
568
|
+
// `establishAuditFloor` skips a store that has a record), so a far-future bound would
|
|
569
|
+
// retire the floor for every table in this database. The purge takes the same clamped
|
|
570
|
+
// bound, so it cannot remove an entry the floor does not cover.
|
|
571
|
+
const pruneEnd = boundedAuditPruneEnd(table.auditStore, before);
|
|
572
|
+
raiseAuditFloor(table.auditStore, pruneEnd);
|
|
573
|
+
const deleted = table.primaryStore.purgeLogs({ before: pruneEnd, includeEntryCounts: true });
|
|
548
574
|
totalResults.log_files_deleted += deleted.length;
|
|
549
575
|
totalResults.entries_deleted += deleted.reduce((acc, file) => acc + file.entries, 0);
|
|
550
576
|
break;
|