@harperfast/harper 5.2.9 → 5.3.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/mcpTools.ts +1 -1
- package/agent/session.ts +25 -14
- package/bin/cliOperations.ts +46 -9
- package/bin/copyDb.ts +282 -59
- package/bin/deploySetup.ts +16 -5
- package/bin/harper.ts +1 -1
- package/bin/help.ts +4 -1
- package/bin/lite.ts +4 -1
- package/bin/restart.ts +59 -3
- package/bin/run.ts +6 -11
- package/bin/upgrade.js +7 -3
- package/bin/workloadIdentity.ts +119 -0
- package/components/Application.ts +2414 -242
- package/components/ApplicationScope.ts +8 -0
- package/components/EntryHandler.ts +59 -39
- package/components/OptionsWatcher.ts +150 -74
- package/components/RuntimeModuleTracker.ts +38 -7
- package/components/Scope.ts +37 -15
- package/components/awaitRestart.ts +84 -0
- package/components/componentLoader.ts +340 -30
- package/components/componentPreparationLock.ts +16 -5
- package/components/mcp/adapters/harperHttp.ts +4 -0
- package/components/mcp/listChanged.ts +4 -0
- package/components/mcp/toolRegistry.ts +2 -0
- package/components/mcp/tools/operations.ts +9 -0
- package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
- package/components/operations.js +270 -78
- package/components/operationsValidation.js +49 -1
- package/components/status/ComponentStatusRegistry.ts +59 -0
- package/config/RootConfigWatcher.ts +80 -34
- package/config/configUtils.ts +291 -34
- package/config/harperConfigEnvVars.ts +170 -27
- package/config-root.schema.json +29 -0
- package/dataLayer/blobBackup.ts +160 -50
- package/dataLayer/delete.ts +6 -1
- package/dataLayer/harperBridge/ResourceBridge.ts +52 -8
- package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
- package/dataLayer/hdbInfoController.ts +34 -1
- package/dataLayer/insert.ts +44 -1
- package/dataLayer/rocksdbBackup.ts +53 -10
- package/dataLayer/schema.ts +11 -1
- package/dataLayer/schemaDescribe.ts +8 -1
- package/dist/agent/mcpTools.js +1 -1
- package/dist/agent/mcpTools.js.map +1 -1
- package/dist/agent/session.d.ts +22 -0
- package/dist/agent/session.js +26 -15
- package/dist/agent/session.js.map +1 -1
- package/dist/bin/cliOperations.js +48 -9
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/copyDb.d.ts +12 -1
- package/dist/bin/copyDb.js +248 -60
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/deploySetup.d.ts +2 -0
- package/dist/bin/deploySetup.js +11 -3
- package/dist/bin/deploySetup.js.map +1 -1
- package/dist/bin/harper.js +1 -1
- package/dist/bin/harper.js.map +1 -1
- package/dist/bin/help.js +4 -1
- package/dist/bin/help.js.map +1 -1
- package/dist/bin/lite.js +4 -1
- package/dist/bin/lite.js.map +1 -1
- package/dist/bin/restart.js +54 -5
- package/dist/bin/restart.js.map +1 -1
- package/dist/bin/run.js +4 -10
- package/dist/bin/run.js.map +1 -1
- package/dist/bin/upgrade.js +4 -3
- package/dist/bin/upgrade.js.map +1 -1
- package/dist/bin/workloadIdentity.d.ts +18 -0
- package/dist/bin/workloadIdentity.js +100 -0
- package/dist/bin/workloadIdentity.js.map +1 -0
- package/dist/components/Application.d.ts +139 -16
- package/dist/components/Application.js +2215 -267
- package/dist/components/Application.js.map +1 -1
- package/dist/components/ApplicationScope.d.ts +8 -0
- package/dist/components/ApplicationScope.js +7 -0
- package/dist/components/ApplicationScope.js.map +1 -1
- package/dist/components/EntryHandler.js +26 -10
- package/dist/components/EntryHandler.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -0
- package/dist/components/OptionsWatcher.js +141 -74
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/RuntimeModuleTracker.js +40 -6
- package/dist/components/RuntimeModuleTracker.js.map +1 -1
- package/dist/components/Scope.js +38 -13
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/awaitRestart.d.ts +33 -0
- package/dist/components/awaitRestart.js +61 -0
- package/dist/components/awaitRestart.js.map +1 -0
- package/dist/components/componentLoader.d.ts +38 -1
- package/dist/components/componentLoader.js +279 -22
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentPreparationLock.d.ts +5 -0
- package/dist/components/componentPreparationLock.js +14 -6
- package/dist/components/componentPreparationLock.js.map +1 -1
- package/dist/components/mcp/adapters/harperHttp.js +4 -0
- package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
- package/dist/components/mcp/listChanged.js +5 -0
- package/dist/components/mcp/listChanged.js.map +1 -1
- package/dist/components/mcp/toolRegistry.d.ts +1 -0
- package/dist/components/mcp/toolRegistry.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +5 -0
- package/dist/components/mcp/tools/operations.js +9 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
- package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
- package/dist/components/operations.js +231 -77
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +49 -1
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
- package/dist/components/status/ComponentStatusRegistry.js +63 -0
- package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
- package/dist/config/RootConfigWatcher.d.ts +7 -1
- package/dist/config/RootConfigWatcher.js +64 -27
- package/dist/config/RootConfigWatcher.js.map +1 -1
- package/dist/config/configUtils.d.ts +9 -1
- package/dist/config/configUtils.js +254 -33
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +16 -0
- package/dist/config/harperConfigEnvVars.js +162 -25
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/dataLayer/blobBackup.d.ts +49 -20
- package/dist/dataLayer/blobBackup.js +139 -50
- package/dist/dataLayer/blobBackup.js.map +1 -1
- package/dist/dataLayer/delete.js +1 -1
- package/dist/dataLayer/delete.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.js +51 -10
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
- package/dist/dataLayer/hdbInfoController.d.ts +10 -0
- package/dist/dataLayer/hdbInfoController.js +30 -1
- package/dist/dataLayer/hdbInfoController.js.map +1 -1
- package/dist/dataLayer/insert.d.ts +9 -1
- package/dist/dataLayer/insert.js +30 -0
- package/dist/dataLayer/insert.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
- package/dist/dataLayer/rocksdbBackup.js +45 -8
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/dataLayer/schema.js +8 -0
- package/dist/dataLayer/schema.js.map +1 -1
- package/dist/dataLayer/schemaDescribe.js +8 -1
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +52 -0
- package/dist/resources/DatabaseTransaction.d.ts +55 -0
- package/dist/resources/DatabaseTransaction.js +459 -45
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.js +22 -4
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
- package/dist/resources/PrimaryRocksDatabase.js +29 -0
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/RecordEncoder.d.ts +20 -0
- package/dist/resources/RecordEncoder.js +94 -9
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/RequestTarget.d.ts +2 -0
- package/dist/resources/RequestTarget.js.map +1 -1
- package/dist/resources/Resource.js +20 -11
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +20 -1
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/RocksIndexStore.d.ts +6 -1
- package/dist/resources/RocksIndexStore.js +24 -9
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/RocksTransactionLogStore.d.ts +14 -1
- package/dist/resources/RocksTransactionLogStore.js +57 -17
- package/dist/resources/RocksTransactionLogStore.js.map +1 -1
- package/dist/resources/Table.d.ts +112 -8
- package/dist/resources/Table.js +1073 -191
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/auditStore.d.ts +11 -2
- package/dist/resources/auditStore.js +325 -71
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/blob.d.ts +129 -9
- package/dist/resources/blob.js +992 -114
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/branchDatabase.d.ts +48 -0
- package/dist/resources/branchDatabase.js +892 -0
- package/dist/resources/branchDatabase.js.map +1 -0
- package/dist/resources/crdt.js +50 -12
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/databases.d.ts +150 -0
- package/dist/resources/databases.js +1200 -239
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/defineTable.d.ts +10 -2
- package/dist/resources/defineTable.js +9 -1
- package/dist/resources/defineTable.js.map +1 -1
- package/dist/resources/graphql.d.ts +1 -1
- package/dist/resources/graphql.js +50 -15
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +1 -0
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +6 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/longLivedTransactions.d.ts +71 -0
- package/dist/resources/longLivedTransactions.js +358 -0
- package/dist/resources/longLivedTransactions.js.map +1 -0
- package/dist/resources/models/backendRegistry.d.ts +26 -0
- package/dist/resources/models/backendRegistry.js +60 -2
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +33 -1
- package/dist/resources/models/bootstrap.js +416 -31
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/recordLock.d.ts +80 -0
- package/dist/resources/recordLock.js +184 -0
- package/dist/resources/recordLock.js.map +1 -0
- package/dist/resources/replayLogs.d.ts +14 -1
- package/dist/resources/replayLogs.js +152 -24
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/replayLogsGuards.d.ts +94 -7
- package/dist/resources/replayLogsGuards.js +111 -7
- package/dist/resources/replayLogsGuards.js.map +1 -1
- package/dist/resources/search.js +158 -6
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transactionBroadcast.d.ts +1 -1
- package/dist/resources/transactionBroadcast.js +2 -2
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +61 -30
- package/dist/security/auth.js.map +1 -1
- package/dist/security/authn/oidc/claims.d.ts +22 -0
- package/dist/security/authn/oidc/claims.js +71 -0
- package/dist/security/authn/oidc/claims.js.map +1 -0
- package/dist/security/authn/oidc/identityToken.d.ts +27 -0
- package/dist/security/authn/oidc/identityToken.js +111 -0
- package/dist/security/authn/oidc/identityToken.js.map +1 -0
- package/dist/security/authn/oidc/jwks.d.ts +25 -0
- package/dist/security/authn/oidc/jwks.js +261 -0
- package/dist/security/authn/oidc/jwks.js.map +1 -0
- package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
- package/dist/security/authn/oidc/providers/generic.js +34 -0
- package/dist/security/authn/oidc/providers/generic.js.map +1 -0
- package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
- package/dist/security/authn/oidc/providers/githubActions.js +129 -0
- package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
- package/dist/security/authn/oidc/providers/index.d.ts +37 -0
- package/dist/security/authn/oidc/providers/index.js +24 -0
- package/dist/security/authn/oidc/providers/index.js.map +1 -0
- package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
- package/dist/security/authn/oidc/tokenExchange.js +306 -0
- package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
- package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
- package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
- package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
- package/dist/security/authn/oidc/types.d.ts +38 -0
- package/dist/security/authn/oidc/types.js +6 -0
- package/dist/security/authn/oidc/types.js.map +1 -0
- package/dist/security/certificateVerification/index.js +40 -11
- package/dist/security/certificateVerification/index.js.map +1 -1
- package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
- package/dist/security/certificateVerification/trustedIssuers.js +79 -0
- package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
- package/dist/security/certificateVerification/types.d.ts +1 -0
- package/dist/security/credentialProvenance.d.ts +35 -0
- package/dist/security/credentialProvenance.js +51 -0
- package/dist/security/credentialProvenance.js.map +1 -0
- package/dist/security/credentialRejection.d.ts +4 -0
- package/dist/security/credentialRejection.js +24 -0
- package/dist/security/credentialRejection.js.map +1 -0
- package/dist/security/deferredAuthentication.d.ts +36 -0
- package/dist/security/deferredAuthentication.js +70 -0
- package/dist/security/deferredAuthentication.js.map +1 -0
- package/dist/security/impersonation.d.ts +21 -0
- package/dist/security/impersonation.js +108 -9
- package/dist/security/impersonation.js.map +1 -1
- package/dist/security/jsLoader.d.ts +6 -0
- package/dist/security/jsLoader.js +75 -15
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/keys.js +301 -71
- package/dist/security/keys.js.map +1 -1
- package/dist/security/operationScope.d.ts +21 -0
- package/dist/security/operationScope.js +36 -0
- package/dist/security/operationScope.js.map +1 -0
- package/dist/security/permissionsTranslator.js +21 -0
- package/dist/security/permissionsTranslator.js.map +1 -1
- package/dist/security/tokenAuthentication.d.ts +19 -1
- package/dist/security/tokenAuthentication.js +191 -10
- package/dist/security/tokenAuthentication.js.map +1 -1
- package/dist/security/user.js +4 -3
- package/dist/security/user.js.map +1 -1
- package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
- package/dist/server/DurableSubscriptionsSession.js +67 -10
- package/dist/server/DurableSubscriptionsSession.js.map +1 -1
- package/dist/server/REST.js +73 -0
- package/dist/server/REST.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -0
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.js +88 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +8 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/jobs/jobProcess.js +6 -2
- package/dist/server/jobs/jobProcess.js.map +1 -1
- package/dist/server/jobs/jobs.js +4 -1
- package/dist/server/jobs/jobs.js.map +1 -1
- package/dist/server/liveSubscriptionAuth.d.ts +26 -4
- package/dist/server/liveSubscriptionAuth.js +105 -39
- package/dist/server/liveSubscriptionAuth.js.map +1 -1
- package/dist/server/loadRootComponents.js +49 -10
- package/dist/server/loadRootComponents.js.map +1 -1
- package/dist/server/mqtt.d.ts +2 -0
- package/dist/server/mqtt.js +165 -30
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/nodeName.d.ts +2 -0
- package/dist/server/nodeName.js +107 -23
- package/dist/server/nodeName.js.map +1 -1
- package/dist/server/serverHelpers/Headers.d.ts +25 -0
- package/dist/server/serverHelpers/Headers.js +139 -1
- package/dist/server/serverHelpers/Headers.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +9 -0
- package/dist/server/serverHelpers/contentTypes.js +32 -25
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
- package/dist/server/serverHelpers/deployValidationState.js +9 -19
- package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
- package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
- package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
- package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
- package/dist/server/serverHelpers/registeredOperations.js +74 -21
- package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
- package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
- package/dist/server/serverHelpers/requestSanitization.js +20 -0
- package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
- package/dist/server/serverHelpers/serverHandlers.js +6 -3
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
- package/dist/server/serverHelpers/serverUtilities.js +90 -20
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
- package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
- package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
- package/dist/server/serverHelpers/uwsServer.js +19 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/static.js +24 -28
- package/dist/server/static.js.map +1 -1
- package/dist/server/storageReclamation.d.ts +12 -0
- package/dist/server/storageReclamation.js +26 -0
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/itc.d.ts +7 -2
- package/dist/server/threads/itc.js +5 -1
- package/dist/server/threads/itc.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +26 -3
- package/dist/server/threads/manageThreads.js +517 -65
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.js +89 -26
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadHeapMemory.d.ts +2 -0
- package/dist/server/threads/threadHeapMemory.js +31 -0
- package/dist/server/threads/threadHeapMemory.js.map +1 -0
- package/dist/server/threads/threadServer.js +46 -15
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/sqlEngine/config.d.ts +1 -3
- package/dist/sqlEngine/config.js +19 -16
- package/dist/sqlEngine/config.js.map +1 -1
- package/dist/sqlTranslator/index.d.ts +1 -1
- package/dist/sqlTranslator/index.js +30 -7
- package/dist/sqlTranslator/index.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.d.ts +7 -0
- package/dist/upgrade/directives/5-3-0.js +148 -0
- package/dist/upgrade/directives/5-3-0.js.map +1 -0
- package/dist/upgrade/directives/directivesController.js +2 -1
- package/dist/upgrade/directives/directivesController.js.map +1 -1
- package/dist/utility/OperationFunctionCaller.js +2 -1
- package/dist/utility/OperationFunctionCaller.js.map +1 -1
- package/dist/utility/common_utils.d.ts +16 -0
- package/dist/utility/common_utils.js +32 -6
- package/dist/utility/common_utils.js.map +1 -1
- package/dist/utility/componentNames.d.ts +8 -0
- package/dist/utility/componentNames.js +12 -1
- package/dist/utility/componentNames.js.map +1 -1
- package/dist/utility/environment/environmentManager.js +3 -6
- package/dist/utility/environment/environmentManager.js.map +1 -1
- package/dist/utility/environment/systemInformation.d.ts +1 -0
- package/dist/utility/environment/systemInformation.js +1 -0
- package/dist/utility/environment/systemInformation.js.map +1 -1
- package/dist/utility/errors/commonErrors.d.ts +2 -0
- package/dist/utility/errors/commonErrors.js +2 -0
- package/dist/utility/errors/commonErrors.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +17 -0
- package/dist/utility/errors/hdbError.js +30 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/globalSchema.d.ts +18 -0
- package/dist/utility/hdbTerms.d.ts +16 -0
- package/dist/utility/hdbTerms.js +18 -2
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +2 -0
- package/dist/utility/logging/harper_logger.js +91 -14
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/logging/logRotator.js +76 -49
- package/dist/utility/logging/logRotator.js.map +1 -1
- package/dist/utility/nodeIdentity.d.ts +9 -0
- package/dist/utility/nodeIdentity.js +58 -0
- package/dist/utility/nodeIdentity.js.map +1 -0
- package/dist/utility/npmUtilities.js +9 -7
- package/dist/utility/npmUtilities.js.map +1 -1
- package/dist/utility/operationPermissions.d.ts +3 -1
- package/dist/utility/operationPermissions.js +16 -1
- package/dist/utility/operationPermissions.js.map +1 -1
- package/dist/utility/operation_authorization.d.ts +10 -7
- package/dist/utility/operation_authorization.js +212 -41
- package/dist/utility/operation_authorization.js.map +1 -1
- package/dist/utility/watchPath.d.ts +29 -0
- package/dist/utility/watchPath.js +68 -0
- package/dist/utility/watchPath.js.map +1 -0
- package/dist/utility/watcherFallback.d.ts +86 -0
- package/dist/utility/watcherFallback.js +278 -1
- package/dist/utility/watcherFallback.js.map +1 -1
- package/dist/validation/configValidator.d.ts +12 -0
- package/dist/validation/configValidator.js +199 -72
- package/dist/validation/configValidator.js.map +1 -1
- package/dist/validation/installValidator.js +12 -0
- package/dist/validation/installValidator.js.map +1 -1
- package/dist/validation/validationWrapper.d.ts +11 -0
- package/dist/validation/validationWrapper.js +16 -3
- package/dist/validation/validationWrapper.js.map +1 -1
- package/index.ts +2 -0
- package/json/systemSchema.json +52 -0
- package/npm-shrinkwrap.json +196 -194
- package/package.json +7 -6
- package/resources/DESIGN.md +114 -52
- package/resources/DatabaseTransaction.ts +521 -43
- package/resources/LMDBTransaction.ts +26 -4
- package/resources/PrimaryRocksDatabase.ts +28 -1
- package/resources/RecordEncoder.ts +103 -8
- package/resources/RequestTarget.ts +2 -0
- package/resources/Resource.ts +17 -9
- package/resources/ResourceInterface.ts +23 -0
- package/resources/RocksIndexStore.ts +30 -9
- package/resources/RocksTransactionLogStore.ts +78 -21
- package/resources/Table.ts +1110 -159
- package/resources/auditStore.ts +331 -73
- package/resources/blob.ts +1029 -110
- package/resources/branchDatabase.ts +941 -0
- package/resources/crdt.ts +76 -12
- package/resources/databases.ts +1310 -252
- package/resources/defineTable.ts +18 -2
- package/resources/graphql.ts +70 -16
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +6 -2
- package/resources/longLivedTransactions.ts +360 -0
- package/resources/models/backendRegistry.ts +84 -2
- package/resources/models/bootstrap.ts +473 -28
- package/resources/recordLock.ts +253 -0
- package/resources/replayLogs.ts +147 -26
- package/resources/replayLogsGuards.ts +171 -8
- package/resources/search.ts +154 -7
- package/resources/transactionBroadcast.ts +3 -3
- package/security/auth.ts +68 -29
- package/security/authn/oidc/claims.ts +72 -0
- package/security/authn/oidc/identityToken.ts +129 -0
- package/security/authn/oidc/jwks.ts +260 -0
- package/security/authn/oidc/providers/generic.ts +40 -0
- package/security/authn/oidc/providers/githubActions.ts +137 -0
- package/security/authn/oidc/providers/index.ts +52 -0
- package/security/authn/oidc/tokenExchange.ts +300 -0
- package/security/authn/oidc/trustPolicyOperations.ts +343 -0
- package/security/authn/oidc/types.ts +41 -0
- package/security/certificateVerification/index.ts +54 -13
- package/security/certificateVerification/trustedIssuers.ts +76 -0
- package/security/certificateVerification/types.ts +1 -0
- package/security/credentialProvenance.ts +47 -0
- package/security/credentialRejection.ts +22 -0
- package/security/deferredAuthentication.ts +71 -0
- package/security/impersonation.ts +117 -12
- package/security/jsLoader.ts +81 -18
- package/security/keys.ts +298 -72
- package/security/operationScope.ts +33 -0
- package/security/permissionsTranslator.js +23 -0
- package/security/tokenAuthentication.ts +233 -12
- package/security/user.ts +4 -3
- package/server/DESIGN.md +183 -16
- package/server/DurableSubscriptionsSession.ts +71 -11
- package/server/REST.ts +79 -1
- package/server/graphqlQuerying.ts +4 -0
- package/server/http.ts +99 -18
- package/server/itc/serverHandlers.js +8 -1
- package/server/jobs/jobProcess.ts +8 -2
- package/server/jobs/jobs.ts +4 -1
- package/server/liveSubscriptionAuth.ts +129 -46
- package/server/loadRootComponents.js +50 -8
- package/server/mqtt.ts +179 -38
- package/server/nodeName.ts +103 -21
- package/server/serverHelpers/Headers.ts +131 -0
- package/server/serverHelpers/contentTypes.ts +29 -21
- package/server/serverHelpers/deployValidationState.ts +24 -13
- package/server/serverHelpers/operationAuthorizationState.ts +34 -3
- package/server/serverHelpers/registeredOperations.ts +79 -22
- package/server/serverHelpers/requestSanitization.ts +15 -0
- package/server/serverHelpers/serverHandlers.js +6 -3
- package/server/serverHelpers/serverUtilities.ts +104 -31
- package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
- package/server/serverHelpers/uwsServer.ts +17 -2
- package/server/static.ts +23 -29
- package/server/storageReclamation.ts +23 -0
- package/server/threads/itc.js +11 -1
- package/server/threads/manageThreads.js +526 -56
- package/server/threads/socketRouter.ts +81 -26
- package/server/threads/threadHeapMemory.ts +26 -0
- package/server/threads/threadServer.js +43 -15
- package/sqlTranslator/index.ts +31 -8
- package/studio/web/assets/Chat-BnCBegQz.js +2267 -0
- package/studio/web/assets/FloatingChat-CoDW1ySS.js +23 -0
- package/studio/web/assets/{abnfDiagram-VCTEODGH-C0_BAZyO.js → abnfDiagram-VCTEODGH-B0BebmD2.js} +1 -1
- package/studio/web/assets/{alertDialog-DIHt7Z0r.js → alertDialog-CQyAJJhl.js} +1 -1
- package/studio/web/assets/{apiToken-c3Rd-w6g.js → apiToken-Bwk5BLXW.js} +1 -1
- package/studio/web/assets/applications-DHxGi7JH.js +296 -0
- package/studio/web/assets/architecture-7GRP2DOG-LB-MLAAb.js +1 -0
- package/studio/web/assets/{architectureDiagram-5GKGNRK7-BWzrASgm.js → architectureDiagram-5GKGNRK7-7SW3GD-K.js} +1 -1
- package/studio/web/assets/authStore-C3Nfubqr.js +3 -0
- package/studio/web/assets/{blockDiagram-NRAW4CY4-BdJX9Khj.js → blockDiagram-I7D4REHJ-BqguiadH.js} +2 -2
- package/studio/web/assets/{button-DhiX-njv.js → button-BIsUKRZq.js} +2 -2
- package/studio/web/assets/{c4Diagram-UCG6FXSJ-CI6MzGmQ.js → c4Diagram-7LVT6UL2-LBNf8t_X.js} +1 -1
- package/studio/web/assets/channel-yictG-U-.js +1 -0
- package/studio/web/assets/{chevron-up-Df2c8uoU.js → chevron-up-DtKGqDn3.js} +1 -1
- package/studio/web/assets/{chunk-TEH6E4GO-P87k5mNi.js → chunk-4HAMMTFA-DWtTut21.js} +1 -1
- package/studio/web/assets/{chunk-75Z2AOVW-BT8tVmks.js → chunk-75Z2AOVW-QGQD6th2.js} +1 -1
- package/studio/web/assets/{chunk-DU6HZSFF-9kAOOmI6.js → chunk-DU6HZSFF-Chq20Ba5.js} +1 -1
- package/studio/web/assets/{chunk-F27PBJKO-BW7ao8AY.js → chunk-F27PBJKO-BVA5EPhV.js} +1 -1
- package/studio/web/assets/{chunk-GMAD6QVW-BNyXpoQO.js → chunk-GMAD6QVW-BeS7S07A.js} +1 -1
- package/studio/web/assets/{chunk-OBVCFTLP-D4wWOqDQ.js → chunk-GVQU2GXP-sbwVIQ8i.js} +1 -1
- package/studio/web/assets/{chunk-G27WJ6UU-COyLMcgK.js → chunk-IMKFNOWR-Bnh3tAVd.js} +1 -1
- package/studio/web/assets/{chunk-JQ64N6SF-Cyz1IeLf.js → chunk-L3NEJ4N5-COfUyKII.js} +1 -1
- package/studio/web/assets/chunk-OSK3NFVY-ByciRftO.js +10 -0
- package/studio/web/assets/{chunk-P2QGCYS3-DmIFY4d7.js → chunk-P2QGCYS3-CP1VhG_c.js} +1 -1
- package/studio/web/assets/{chunk-POPQ4Y6H-BPrvMyKz.js → chunk-POPQ4Y6H-ClWhhkwW.js} +1 -1
- package/studio/web/assets/{chunk-PWAF6VOD-2zB6IW9i.js → chunk-PWAF6VOD-1z1THyS5.js} +1 -1
- package/studio/web/assets/{chunk-RHFEMEQ7-2FgyI8YU.js → chunk-SHT3W25Y-LpQkMsah.js} +2 -2
- package/studio/web/assets/{chunk-SVP7TREG-FwtbH2QC.js → chunk-SVP7TREG-jtdAHw0S.js} +1 -1
- package/studio/web/assets/{chunk-LCL6LL3I-HOzK_ppE.js → chunk-TICWLB2K-VOwzetX-.js} +1 -1
- package/studio/web/assets/classDiagram-ZZMXUADV-VaEwSy_g.js +1 -0
- package/studio/web/assets/classDiagram-v2-VYDZK3BY-VaEwSy_g.js +1 -0
- package/studio/web/assets/{createLucideIcon-BKGPfjm2.js → createLucideIcon-CzW9508A.js} +1 -1
- package/studio/web/assets/{cssMode-CEN2mzSA.js → cssMode-s0cWI-_M.js} +1 -1
- package/studio/web/assets/{cynefin-OW5HDTMX-BRkpLFQV.js → cynefin-OW5HDTMX-BbdbCvub.js} +1 -1
- package/studio/web/assets/{cynefinDiagram-5FMLGOSQ-CHT1DaX6.js → cynefinDiagram-5FMLGOSQ-TP-aIqbt.js} +1 -1
- package/studio/web/assets/{dagre-3AP2YEHR-DpUXBh63.js → dagre-GXQ25YYZ-DShnGpGo.js} +1 -1
- package/studio/web/assets/{diagram-S7CK7UJ4-BuymVFZT.js → diagram-S7CK7UJ4-aoCVTtcy.js} +1 -1
- package/studio/web/assets/{diagram-UQ7AKVKN-CyP148RM.js → diagram-UQ7AKVKN-DglXtQ6x.js} +1 -1
- package/studio/web/assets/{diagram-VSXAHHWV-CoCAg3M9.js → diagram-VSXAHHWV-fhEdmkwM.js} +1 -1
- package/studio/web/assets/{diagram-VX7I27RA-BpOqCFca.js → diagram-VX7I27RA-DccVJet6.js} +1 -1
- package/studio/web/assets/{diagram-Z3DM3KII-Bfpw7Vbj.js → diagram-Z3DM3KII-D-RyJJb7.js} +1 -1
- package/studio/web/assets/{dialog-CBf0Mr1d.js → dialog-Cn2uWgD4.js} +1 -1
- package/studio/web/assets/{dist-lkA3O3eM.js → dist-DP8UjMB_.js} +1 -1
- package/studio/web/assets/{download-BtTOBem-.js → download-B5T5r7ss.js} +1 -1
- package/studio/web/assets/{ebnfDiagram-PWID7BFC-DS_6aWqL.js → ebnfDiagram-PWID7BFC-DJGpIpz_.js} +1 -1
- package/studio/web/assets/{editor-D8oDeCTL.js → editor-DNcRHK54.js} +1 -1
- package/studio/web/assets/{erDiagram-SSCWMZ5O-DJNk6Fgw.js → erDiagram-RLTQ6QDP-CIfNlgkC.js} +1 -1
- package/studio/web/assets/eventmodeling-NTZA5JFV-CLxnp2CR.js +1 -0
- package/studio/web/assets/flowDiagram-HODETNUW-BIbhmz9f.js +1 -0
- package/studio/web/assets/{ganttDiagram-EL5Y4UJY-2pOExxMY.js → ganttDiagram-EL5Y4UJY-BxToTzzD.js} +1 -1
- package/studio/web/assets/{getAnalytics-D4LKGeVy.js → getAnalytics-GHK8ORfM.js} +1 -1
- package/studio/web/assets/{gitGraph-4MIJSDKK-CH5ZxwzF.js → gitGraph-4MIJSDKK-D2s2w8lE.js} +1 -1
- package/studio/web/assets/{gitGraphDiagram-WWUBYQGX-DVIsIhbO.js → gitGraphDiagram-WWUBYQGX-Dwntd4-x.js} +1 -1
- package/studio/web/assets/{html-u3vOg7LJ.js → html-Bdssedlg.js} +1 -1
- package/studio/web/assets/{htmlMode-DyO31v-P.js → htmlMode-CoDlJ3fw.js} +1 -1
- package/studio/web/assets/{index-Cxj2_wsl.css → index-7RMEgVG1.css} +1 -1
- package/studio/web/assets/index-D6sxmFLR.js +824 -0
- package/studio/web/assets/index.lazy-tmU5BS8s.js +2 -0
- package/studio/web/assets/{info-A6RAGUB7-CPQfTnaG.js → info-A6RAGUB7-DYjkvb0C.js} +1 -1
- package/studio/web/assets/{infoDiagram-RXCK75RN-DlwLYlwm.js → infoDiagram-27XIBGKW-Bnp1FJE5.js} +1 -1
- package/studio/web/assets/{ishikawaDiagram-5VMMS53U-BRXRp29U.js → ishikawaDiagram-5VMMS53U-D9Xh2r6X.js} +1 -1
- package/studio/web/assets/{javascript-CUvxOyTC.js → javascript-B8meVSTH.js} +1 -1
- package/studio/web/assets/{journeyDiagram-EYS64GPL-B0ou8k0n.js → journeyDiagram-3NMN7TZE-CokIi6ll.js} +2 -2
- package/studio/web/assets/{jsonMode-f_IwbF3D.js → jsonMode-DpIPd35T.js} +1 -1
- package/studio/web/assets/{kanban-definition-3QL26DDD-uYg7iYzp.js → kanban-definition-UXKFOSKX-CukSFJfX.js} +1 -1
- package/studio/web/assets/{languageServices-DXtZ6rEF.js → languageServices-C_5FMJzQ.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-B4pCF1zO.js → lspLanguageFeatures-BIzNBzPK.js} +1 -1
- package/studio/web/assets/{mermaid-parser.core-Ck-fC8b7.js → mermaid-parser.core-BlEsOWNO.js} +3 -3
- package/studio/web/assets/{mermaid.core-CP8aNNYm.js → mermaid.core-BlkGaMIH.js} +5 -5
- package/studio/web/assets/{mindmap-definition-FBJOCRG2-CgTZ-rit.js → mindmap-definition-YA3MSWOX-IprMc_0j.js} +1 -1
- package/studio/web/assets/{notifications-D3tIQ4sg.js → notifications-CQf18QKb.js} +1 -1
- package/studio/web/assets/notifications-CvZivSbh.js +1 -0
- package/studio/web/assets/{packet-AYTQ26CC-DEyoPtPb.js → packet-AYTQ26CC-Bi3V04Zi.js} +1 -1
- package/studio/web/assets/{pegDiagram-XKGWAZYB-DrD-7sD9.js → pegDiagram-XKGWAZYB-BNuPDLZY.js} +1 -1
- package/studio/web/assets/{pie-WAS4IAKB-wjj-EI1d.js → pie-WAS4IAKB-_6DoDbng.js} +1 -1
- package/studio/web/assets/{pieDiagram-E7YTZNPT-GntqDCzv.js → pieDiagram-E7YTZNPT-DqNb6Ht2.js} +1 -1
- package/studio/web/assets/{profile-DZWU7MgT.js → profile-DdOwtntb.js} +1 -1
- package/studio/web/assets/{quadrantDiagram-AXDQQJYC-0UeqXQGd.js → quadrantDiagram-AXDQQJYC-BGH9E2YR.js} +1 -1
- package/studio/web/assets/{radar-RG4KPBEZ-DFSA5h7k.js → radar-RG4KPBEZ-DDdVczcL.js} +1 -1
- package/studio/web/assets/{railroad-74A4TZTK-CaOUG9wR.js → railroad-74A4TZTK-BJUP4Jds.js} +1 -1
- package/studio/web/assets/railroad-abnf-HS5TGJTU-Bm3L1L0h.js +1 -0
- package/studio/web/assets/railroad-ebnf-LZEXJU2U-CKzLGlkw.js +1 -0
- package/studio/web/assets/railroad-peg-WCYAUIDC-S8xLjslx.js +1 -0
- package/studio/web/assets/{railroadDiagram-O6MQD6OU-yHUELZaV.js → railroadDiagram-O6MQD6OU-DGTPh2KZ.js} +1 -1
- package/studio/web/assets/{regions-CkyurXzE.js → regions-n69fwagr.js} +1 -1
- package/studio/web/assets/{register-BUyhWjBO.js → register-PfWTCXWB.js} +3 -3
- package/studio/web/assets/{requirementDiagram-EFPCY7ZU-DNEGFjuW.js → requirementDiagram-BXWQKSXE-BJnO6uLz.js} +1 -1
- package/studio/web/assets/{sankeyDiagram-P5KCCOFB-DpyAmSVR.js → sankeyDiagram-P5KCCOFB-0vSOdymH.js} +1 -1
- package/studio/web/assets/{sequenceDiagram-WJ2MYXX4-TyaT7xNk.js → sequenceDiagram-WJ2MYXX4-hETizDWE.js} +1 -1
- package/studio/web/assets/{setComponentFile-CeyKSZAa.js → setComponentFile-Bg6O7X0S.js} +1 -1
- package/studio/web/assets/{setup-D2kn7cAA.js → setup-CUx_aUDl.js} +2 -2
- package/studio/web/assets/{stateDiagram-HBIQ2CUA-CeEdTArZ.js → stateDiagram-D77RDMKH-CdYQ_KtC.js} +1 -1
- package/studio/web/assets/stateDiagram-v2-MP3YSRHH-CdKuzQMT.js +1 -0
- package/studio/web/assets/status-D7BVKqX9.js +61 -0
- package/studio/web/assets/{swimlanes-XN3QIQJK-B54FmF46.js → swimlanes-42K2YHIH-B8cHIpU4.js} +1 -1
- package/studio/web/assets/swimlanesDiagram-VR7AAH4N-DmOSJwaH.js +8 -0
- package/studio/web/assets/{tabs-B_G5zscN.js → tabs-BrHu7gJi.js} +1 -1
- package/studio/web/assets/{timeline-definition-24CTP7MA-D-a9ujbo.js → timeline-definition-24CTP7MA-BJWYSXqF.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-C0UW6rI2.js → toggleHighContrast-DBSyXzMr.js} +1 -1
- package/studio/web/assets/{treeView-Q6P3EWNA-CrW_6JnS.js → treeView-Q6P3EWNA-qxe_v6CQ.js} +1 -1
- package/studio/web/assets/{treemap-WGGIJYW6-BxyYLdP_.js → treemap-WGGIJYW6-dDo97XXF.js} +1 -1
- package/studio/web/assets/{tsMode-DBC0zmDx.js → tsMode-BByKCjBS.js} +1 -1
- package/studio/web/assets/{typescript-DApRQir3.js → typescript-DDLnLpw9.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-DB6JStU1.js → useEntityRestURL-31CHGaHk.js} +1 -1
- package/studio/web/assets/{useLocalStorage-Dtj1QS8_.js → useLocalStorage-BqMR3D8_.js} +1 -1
- package/studio/web/assets/vendor-core-c2JRRJpV.js +58 -0
- package/studio/web/assets/vendor-datadog-CLUcJXOo.js +6 -0
- package/studio/web/assets/{vendor-react-Dyj4O3HE.js → vendor-react-CJV_K1u4.js} +1 -1
- package/studio/web/assets/vendor-tanstack-DxzraizX.js +1 -0
- package/studio/web/assets/{vendor-ui-vhu-UHhF.js → vendor-ui-BUjK0h8a.js} +2 -2
- package/studio/web/assets/{vennDiagram-4TSXK5OY-Cy7s7Mpy.js → vennDiagram-4TSXK5OY-A3i-lCdl.js} +1 -1
- package/studio/web/assets/{wardley-WFR3VGLG-BeBL35g2.js → wardley-WFR3VGLG-B0ik-_6g.js} +1 -1
- package/studio/web/assets/{wardleyDiagram-VM6X3IG4-BylmIGSg.js → wardleyDiagram-VM6X3IG4-CjrkKWUR.js} +1 -1
- package/studio/web/assets/{workers-C0bFIedw.js → workers-pR3jRY9D.js} +1 -1
- package/studio/web/assets/x-DIzaLEdK.js +1 -0
- package/studio/web/assets/{xml-HWd01lU-.js → xml-2iRnMhQO.js} +1 -1
- package/studio/web/assets/{xychartDiagram-S5SC5T6Z-CoKALMXr.js → xychartDiagram-S5SC5T6Z-Biok4GYV.js} +1 -1
- package/studio/web/assets/{yaml-CIH0Nt-h.js → yaml-Bf92gJpd.js} +1 -1
- package/studio/web/index.html +14 -14
- package/upgrade/directives/5-3-0.ts +132 -0
- package/upgrade/directives/directivesController.ts +2 -1
- package/utility/OperationFunctionCaller.ts +2 -1
- package/utility/common_utils.ts +30 -5
- package/utility/componentNames.ts +12 -0
- package/utility/environment/environmentManager.ts +3 -7
- package/utility/environment/systemInformation.ts +7 -0
- package/utility/errors/commonErrors.ts +4 -0
- package/utility/errors/hdbError.ts +29 -0
- package/utility/hdbTerms.ts +17 -0
- package/utility/logging/harper_logger.ts +87 -14
- package/utility/logging/logRotator.ts +72 -45
- package/utility/nodeIdentity.ts +45 -0
- package/utility/npmUtilities.ts +10 -8
- package/utility/operationPermissions.ts +18 -1
- package/utility/operation_authorization.ts +231 -42
- package/utility/watchPath.ts +63 -0
- package/utility/watcherFallback.ts +270 -0
- package/validation/configValidator.ts +209 -72
- package/validation/installValidator.ts +15 -0
- package/validation/validationWrapper.ts +18 -4
- package/studio/web/assets/Chat-JpO8EtUu.js +0 -2067
- package/studio/web/assets/FloatingChat-Bcj3xSZu.js +0 -23
- package/studio/web/assets/applications-ByqLRKyZ.js +0 -296
- package/studio/web/assets/architecture-7GRP2DOG-DNdx5tEU.js +0 -1
- package/studio/web/assets/authStore-qKmCZcaf.js +0 -3
- package/studio/web/assets/channel-DtCV8PTL.js +0 -1
- package/studio/web/assets/chunk-R7TYR2AO-Irip67yr.js +0 -10
- package/studio/web/assets/classDiagram-DTDB5LWJ-DbO_dCNE.js +0 -1
- package/studio/web/assets/classDiagram-v2-JRS7N3AN-DbO_dCNE.js +0 -1
- package/studio/web/assets/eventmodeling-NTZA5JFV-5jbe4A5P.js +0 -1
- package/studio/web/assets/flowDiagram-A5DVABFB-Dp9Ezlow.js +0 -1
- package/studio/web/assets/index-aSt5tY-L.js +0 -824
- package/studio/web/assets/index.lazy-B9jiPwT8.js +0 -2
- package/studio/web/assets/notifications-CUoYgU98.js +0 -1
- package/studio/web/assets/railroad-abnf-HS5TGJTU-Bc0Qi0WH.js +0 -1
- package/studio/web/assets/railroad-ebnf-LZEXJU2U-G8rVVZ2C.js +0 -1
- package/studio/web/assets/railroad-peg-WCYAUIDC-CrehKBhC.js +0 -1
- package/studio/web/assets/stateDiagram-v2-4QOOHH4V-D4tuw9Su.js +0 -1
- package/studio/web/assets/status-D7Xn5ePA.js +0 -61
- package/studio/web/assets/swimlanesDiagram-VK2B7HYN-XOhmNEvq.js +0 -8
- package/studio/web/assets/vendor-core-RCcadM3e.js +0 -73
- package/studio/web/assets/vendor-datadog-BRv-mOv1.js +0 -6
- package/studio/web/assets/vendor-tanstack-BiFWSB3W.js +0 -1
- package/studio/web/assets/x-B9o9hsep.js +0 -1
- /package/studio/web/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
package/resources/blob.ts
CHANGED
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { addExtension, pack, Packr } from 'msgpackr';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
readFile,
|
|
17
|
+
rename,
|
|
18
|
+
statfs,
|
|
19
|
+
readdir,
|
|
20
|
+
rmdir,
|
|
21
|
+
open as openFile,
|
|
22
|
+
type FileHandle,
|
|
23
|
+
unlink as unlinkPromised,
|
|
24
|
+
} from 'node:fs/promises';
|
|
16
25
|
import {
|
|
17
26
|
close,
|
|
18
27
|
closeSync,
|
|
@@ -27,21 +36,24 @@ import {
|
|
|
27
36
|
unlink,
|
|
28
37
|
readdirSync,
|
|
29
38
|
existsSync,
|
|
39
|
+
fstat,
|
|
30
40
|
fstatSync,
|
|
41
|
+
stat,
|
|
31
42
|
watch,
|
|
32
43
|
write,
|
|
33
44
|
statSync,
|
|
34
45
|
writeFile,
|
|
35
46
|
type FSWatcher,
|
|
36
47
|
} from 'node:fs';
|
|
37
|
-
import type { StatsFs } from 'node:fs';
|
|
48
|
+
import type { ReadStream, StatsFs } from 'node:fs';
|
|
38
49
|
import { createDeflate, createInflate, inflate } from 'node:zlib';
|
|
39
|
-
import { Readable, pipeline } from 'node:stream';
|
|
50
|
+
import { Readable, Transform, pipeline } from 'node:stream';
|
|
40
51
|
import { ensureDirSync } from 'fs-extra';
|
|
41
52
|
import { get as envGet, getHdbBasePath } from '../utility/environment/environmentManager.ts';
|
|
42
53
|
import { CONFIG_PARAMS, MAX_SET_TIMEOUT_MS } from '../utility/hdbTerms.ts';
|
|
43
54
|
import { join, dirname } from 'path';
|
|
44
55
|
import { logger } from '../utility/logging/logger.ts';
|
|
56
|
+
import { resolveWatchTarget } from '../utility/watchPath.ts';
|
|
45
57
|
import type { RootDatabase } from 'lmdb';
|
|
46
58
|
import { asyncSerialization, hasAsyncSerialization } from '../server/serverHelpers/contentTypes.ts';
|
|
47
59
|
import { getHeapStatistics } from 'node:v8';
|
|
@@ -54,9 +66,10 @@ type StorageInfo = {
|
|
|
54
66
|
store?: any;
|
|
55
67
|
filePath?: string;
|
|
56
68
|
recordId?: number;
|
|
57
|
-
contentBuffer?:
|
|
69
|
+
contentBuffer?: Buffer;
|
|
58
70
|
source?: Readable;
|
|
59
71
|
compress?: boolean;
|
|
72
|
+
storedCodec?: 'deflate'; // the source bytes are already the stored (compressed) representation
|
|
60
73
|
flush?: boolean;
|
|
61
74
|
start?: number;
|
|
62
75
|
end?: number;
|
|
@@ -64,18 +77,30 @@ type StorageInfo = {
|
|
|
64
77
|
saved?: boolean; // saving settled successfully; distinguishes durable from still-streaming when fileId is already assigned
|
|
65
78
|
asString?: string;
|
|
66
79
|
deleteOnFailure?: boolean;
|
|
80
|
+
// Slices share this state with their source, so condemning either instance invalidates every view
|
|
81
|
+
// that can still re-encode the same fileId.
|
|
82
|
+
fileState?: { discarded?: boolean };
|
|
83
|
+
// The file `blobFileMissingOrIncompleteAsync` last found damaged, for the locked recheck to recognize
|
|
84
|
+
probedDamage?: { fileSize: number; header: Buffer };
|
|
67
85
|
};
|
|
86
|
+
type BlobFileInfo = { store?: any; fileId?: string };
|
|
87
|
+
|
|
88
|
+
function discardStorage(storageInfo: StorageInfo): void {
|
|
89
|
+
(storageInfo.fileState ??= {}).discarded = true;
|
|
90
|
+
}
|
|
68
91
|
const FILE_STORAGE_THRESHOLD = 8192; // if the file is below this size, we will store it in memory, or within the record itself, otherwise we will store it in a file
|
|
69
92
|
// We want to keep the file path private (but accessible to the extension)
|
|
70
93
|
const HEADER_SIZE = 8;
|
|
71
94
|
const UNCOMPRESSED_TYPE = 0;
|
|
72
|
-
const DEFLATE_TYPE = 1;
|
|
95
|
+
export const DEFLATE_TYPE = 1;
|
|
73
96
|
const ERROR_TYPE = 0xff;
|
|
74
97
|
// A write that aborted on a re-streamable external source (replication receive / origin fetch) stamps the
|
|
75
98
|
// file with this type so a downstream read returns 503 (retry) rather than 500 (confidently incomplete).
|
|
76
|
-
// The
|
|
77
|
-
//
|
|
78
|
-
//
|
|
99
|
+
// The re-stream builds a fresh blob and takes a NEW file id (harper-pro's `createBlob` → `saveBlob`), so
|
|
100
|
+
// this stub is orphaned rather than overwritten; a terminal give-up unlinks it (→ 404). No path rewrites
|
|
101
|
+
// a published blob file in place: `repairBlobFile` is the only same-id writer and it publishes via a
|
|
102
|
+
// `.repair` temp + rename. Distinct from ERROR_TYPE (a permanent corrupt/error stub, replicated as-is).
|
|
103
|
+
// See harper-pro#481.
|
|
79
104
|
const PENDING_TYPE = 0xfe;
|
|
80
105
|
const BLOB_REPAIR_SUFFIX = '.repair';
|
|
81
106
|
const DEFAULT_HEADER = new Uint8Array([0, UNCOMPRESSED_TYPE, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
|
|
@@ -219,6 +244,54 @@ const BLOB_GONE_STATUS = 404;
|
|
|
219
244
|
// retryable class from the permanent ones without duplicating the status code.
|
|
220
245
|
export const BLOB_UNAVAILABLE_STATUS = 503;
|
|
221
246
|
const BLOB_CORRUPT_STATUS = 500;
|
|
247
|
+
/**
|
|
248
|
+
* Arm the one-shot watch that wakes an in-progress blob read of `filePath`, or return `undefined`
|
|
249
|
+
* for a read that has to poll instead. `isLive` decides whether the read still owns a callback's
|
|
250
|
+
* watcher: a callback from one it has already replaced must not act, or it would close the live
|
|
251
|
+
* watcher and start a second read at the same position. `onFailure` runs only for a live watcher
|
|
252
|
+
* that fails after registration — a registration that throws latches `mustPoll` and leaves the
|
|
253
|
+
* caller in its own no-watcher branch.
|
|
254
|
+
*/
|
|
255
|
+
export function watchInProgressFile(
|
|
256
|
+
filePath: string,
|
|
257
|
+
watchTarget: { path: string; mustPoll: boolean },
|
|
258
|
+
handlers: {
|
|
259
|
+
isLive: (watcher: FSWatcher) => boolean;
|
|
260
|
+
onChange: () => void;
|
|
261
|
+
onFailure: () => void;
|
|
262
|
+
},
|
|
263
|
+
watchFile: typeof watch = watch
|
|
264
|
+
): FSWatcher | undefined {
|
|
265
|
+
if (watchTarget.mustPoll) return undefined;
|
|
266
|
+
let watcher: FSWatcher;
|
|
267
|
+
try {
|
|
268
|
+
watcher = watchFile(watchTarget.path, { persistent: false }, () => {
|
|
269
|
+
if (handlers.isLive(watcher)) handlers.onChange();
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// fs.watch throws synchronously when the OS watcher pool is exhausted (EMFILE/ENOSPC). Latch
|
|
273
|
+
// on the stream-scoped target, or the caller's re-poll re-enters here and re-attempts the same
|
|
274
|
+
// failing registration for the rest of the read.
|
|
275
|
+
logger.debug?.(`Could not watch ${filePath} for in-progress writes, polling instead:`, error);
|
|
276
|
+
watchTarget.mustPoll = true;
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
// An FSWatcher that fails after registration emits 'error'; with no listener Node rethrows it out
|
|
280
|
+
// of the watcher callback.
|
|
281
|
+
watcher.on('error', (error) => {
|
|
282
|
+
if (!handlers.isLive(watcher)) return;
|
|
283
|
+
logger.debug?.(`Watch of ${filePath} failed, polling instead:`, error);
|
|
284
|
+
watchTarget.mustPoll = true;
|
|
285
|
+
try {
|
|
286
|
+
watcher.close();
|
|
287
|
+
} catch {
|
|
288
|
+
// A close() that throws here must not skip onFailure — the poll fallback is what recovers
|
|
289
|
+
// the read, and the exception would otherwise escape this listener uncaught.
|
|
290
|
+
}
|
|
291
|
+
handlers.onFailure();
|
|
292
|
+
});
|
|
293
|
+
return watcher;
|
|
294
|
+
}
|
|
222
295
|
class BlobReadError extends Error {
|
|
223
296
|
statusCode: number;
|
|
224
297
|
code?: string;
|
|
@@ -234,6 +307,45 @@ class BlobReadError extends Error {
|
|
|
234
307
|
if (statusCode === BLOB_GONE_STATUS) this.code = 'ENOENT';
|
|
235
308
|
}
|
|
236
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Wait until no writer holds the blob's write lock, bounded by the blob read timeout. This is the
|
|
312
|
+
* completeness authority for a compressed body: its header stores the uncompressed size, so unlike
|
|
313
|
+
* an uncompressed blob its length on disk proves nothing, and inflating a partial deflate stream
|
|
314
|
+
* errors. Resolving means the writer (if any) has finished — not that this caller holds the lock.
|
|
315
|
+
*/
|
|
316
|
+
function waitForBlobWriteCompletion(storageInfo: StorageInfo): Promise<void> {
|
|
317
|
+
const store = storageInfo.store;
|
|
318
|
+
const lockKey = storageInfo.fileId + ':blob';
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
// Probe first: the writer lock is free on the overwhelming majority of reads, so arm the
|
|
321
|
+
// timeout only when a writer actually holds it — an uncontended compressed read pays a
|
|
322
|
+
// tryLock, not a setTimeout.
|
|
323
|
+
let settled = false;
|
|
324
|
+
let timer: NodeJS.Timeout;
|
|
325
|
+
function onReleased() {
|
|
326
|
+
if (settled) return;
|
|
327
|
+
settled = true;
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
resolve();
|
|
330
|
+
}
|
|
331
|
+
if (store.tryLock(lockKey, onReleased)) {
|
|
332
|
+
store.unlock(lockKey);
|
|
333
|
+
return resolve();
|
|
334
|
+
}
|
|
335
|
+
timer = setTimeout(() => {
|
|
336
|
+
if (settled) return;
|
|
337
|
+
settled = true;
|
|
338
|
+
reject(
|
|
339
|
+
new BlobReadError(
|
|
340
|
+
`Blob ${storageInfo.fileId} is unavailable; the in-progress write did not complete in time`,
|
|
341
|
+
BLOB_UNAVAILABLE_STATUS
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
}, getBlobReadTimeout());
|
|
345
|
+
timer.unref();
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
237
349
|
// We want FileBackedBlob instances to be an instanceof Blob, but we don't want to actually extend the class and call Blob's constructor, which is quite expensive because it has to set it up as a transferrable.
|
|
238
350
|
function InstanceOfBlobWithNoConstructor() {}
|
|
239
351
|
InstanceOfBlobWithNoConstructor.prototype = Blob.prototype;
|
|
@@ -335,7 +447,6 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
335
447
|
}
|
|
336
448
|
|
|
337
449
|
size = Number(headerValue & 0xffffffffffffn);
|
|
338
|
-
if (size < end) size = end;
|
|
339
450
|
if (size < UNKNOWN_SIZE) {
|
|
340
451
|
if (isFullRead && descriptorSize != null && descriptorSize < UNKNOWN_SIZE && size !== descriptorSize) {
|
|
341
452
|
// the header claims a different (uncompressed) size than the record descriptor: the file
|
|
@@ -465,9 +576,34 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
465
576
|
const pending = waitForCompletion(size === UNKNOWN_SIZE, true);
|
|
466
577
|
if (pending) return pending;
|
|
467
578
|
return new Promise<Buffer>((resolve, reject) => {
|
|
468
|
-
// start/end index into the UNCOMPRESSED content, so inflate first, then slice
|
|
469
|
-
|
|
470
|
-
|
|
579
|
+
// start/end index into the UNCOMPRESSED content, so inflate first, then slice — with
|
|
580
|
+
// output capped at the header's declared size, so a body that inflates past its own
|
|
581
|
+
// header (a lying or corrupted header) is refused instead of allocating without bound,
|
|
582
|
+
// matching the streaming read path's ceiling. zlib rejects a cap of 0, so an empty
|
|
583
|
+
// blob's cap is 1 and the exact-length check below covers it.
|
|
584
|
+
inflate(rawBytes.subarray(HEADER_SIZE), { maxOutputLength: Math.max(size, 1) }, (error, result) => {
|
|
585
|
+
if (error) {
|
|
586
|
+
const code = (error as { code?: string }).code ?? '';
|
|
587
|
+
reject(
|
|
588
|
+
code === 'ERR_BUFFER_TOO_LARGE'
|
|
589
|
+
? new BlobReadError(
|
|
590
|
+
`Blob ${filePath} inflates past its declared size of ${size}`,
|
|
591
|
+
BLOB_CORRUPT_STATUS
|
|
592
|
+
)
|
|
593
|
+
: code.startsWith('Z_')
|
|
594
|
+
? new BlobReadError(
|
|
595
|
+
`Blob ${filePath} is not a valid deflate stream: ${error.message}`,
|
|
596
|
+
BLOB_CORRUPT_STATUS
|
|
597
|
+
)
|
|
598
|
+
: error
|
|
599
|
+
);
|
|
600
|
+
} else if (result.length !== size)
|
|
601
|
+
reject(
|
|
602
|
+
new BlobReadError(
|
|
603
|
+
`Blob ${filePath} inflates to ${result.length} bytes, not its declared size of ${size}`,
|
|
604
|
+
BLOB_CORRUPT_STATUS
|
|
605
|
+
)
|
|
606
|
+
);
|
|
471
607
|
else resolve(sliceContent(result));
|
|
472
608
|
});
|
|
473
609
|
});
|
|
@@ -515,6 +651,19 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
515
651
|
let position = 0;
|
|
516
652
|
let totalContentRead = 0;
|
|
517
653
|
let watcher: FSWatcher;
|
|
654
|
+
// Drop the live watcher before closing it, so a callback racing the close cannot pass its
|
|
655
|
+
// `isLive` check — and so a close() on an already-failed handle cannot abandon the teardown
|
|
656
|
+
// it was part of. Every site that retires this watcher goes through here.
|
|
657
|
+
const closeWatcher = () => {
|
|
658
|
+
const opened = watcher;
|
|
659
|
+
watcher = null;
|
|
660
|
+
try {
|
|
661
|
+
opened?.close();
|
|
662
|
+
} catch (error) {
|
|
663
|
+
logger.debug?.(`Could not close the in-progress watch of ${filePath}:`, error);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
let watchTarget: { path: string; mustPoll: boolean };
|
|
518
667
|
let timer: NodeJS.Timeout;
|
|
519
668
|
// The start() open-retry timer lives in a different scope/phase than pull()'s `timer`; track it
|
|
520
669
|
// separately so a cancel() during the file-creation wait clears it instead of leaking an fd (#1457).
|
|
@@ -543,6 +692,181 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
543
692
|
// full reads can be compared against the header.
|
|
544
693
|
const descriptorSize = this.size;
|
|
545
694
|
const isFullRead = start === undefined && end === undefined;
|
|
695
|
+
let compressedRead: AsyncGenerator<Buffer> | undefined;
|
|
696
|
+
let teardownCompressedRead: (() => void) | undefined;
|
|
697
|
+
// `bodyPrefix` is what the first raw read already pulled past the header; `bodyPosition` is where
|
|
698
|
+
// the file read continues. Both stay valid across the writer wait because a body is only ever
|
|
699
|
+
// appended to, and the stamps that do overwrite it (PENDING/ERROR) change the header re-read below.
|
|
700
|
+
async function* readCompressedBlob(bodyPrefix: Buffer, bodyPosition: number): AsyncGenerator<Buffer> {
|
|
701
|
+
// The writer's lock is the only completeness authority for a compressed body (the header
|
|
702
|
+
// stores the uncompressed size, so body length proves nothing; a partial deflate stream
|
|
703
|
+
// errors on inflate). Wait for it — bounded, like every other blob read wait (#1423).
|
|
704
|
+
await waitForBlobWriteCompletion(storageInfo);
|
|
705
|
+
if (cancelled || fd == null) return;
|
|
706
|
+
// An in-place repair renames a fresh (uncompressed) file over the path while holding the
|
|
707
|
+
// writer lock, so the descriptor we opened before the wait can be left on the orphaned inode.
|
|
708
|
+
// The repair may finish either during our wait or in the gap between the first read and the
|
|
709
|
+
// lock probe, so detect it by file identity rather than by whether we waited: if the path now
|
|
710
|
+
// resolves to a different inode than our descriptor, the file was swapped. Reopen so the header
|
|
711
|
+
// re-read and the stream below see the current file — a repaired (uncompressed) header is then
|
|
712
|
+
// reported retryable 503 (mirroring openStoredBlobBody), and the prefix read from the old inode
|
|
713
|
+
// is discarded. If the inode is unchanged the descriptor is still valid and the same-fd re-read
|
|
714
|
+
// still catches an in-place PENDING/ERROR stamp (harper-pro#481).
|
|
715
|
+
let fileSwapped = false;
|
|
716
|
+
try {
|
|
717
|
+
const currentFd = fd;
|
|
718
|
+
const [openStat, pathStat] = await Promise.all([
|
|
719
|
+
new Promise<{ ino: number }>((res, rej) => fstat(currentFd, (error, s) => (error ? rej(error) : res(s)))),
|
|
720
|
+
new Promise<{ ino: number }>((res, rej) => stat(filePath, (error, s) => (error ? rej(error) : res(s)))),
|
|
721
|
+
]);
|
|
722
|
+
fileSwapped = openStat.ino !== pathStat.ino;
|
|
723
|
+
} catch {
|
|
724
|
+
// the path is momentarily unstatable, or the descriptor was closed by a concurrent cancel;
|
|
725
|
+
// fall through — the cancellation recheck below returns, and otherwise the header re-read
|
|
726
|
+
// reports the outcome. The blob hold makes a vanished path unlikely here.
|
|
727
|
+
}
|
|
728
|
+
if (cancelled || fd == null) return; // the identity stat awaited; a cancel may have landed
|
|
729
|
+
if (fileSwapped) {
|
|
730
|
+
// Null `fd` before closing it: a closed descriptor number left in `fd` across the await
|
|
731
|
+
// below would be closed a second time by a concurrent cancel()/closeFd() — after the OS may
|
|
732
|
+
// have reassigned it to an unrelated file or socket (#1457).
|
|
733
|
+
const staleFd = fd;
|
|
734
|
+
fd = null;
|
|
735
|
+
close(staleFd);
|
|
736
|
+
fd = await new Promise<number>((resolveOpen, rejectOpen) =>
|
|
737
|
+
open(filePath, 'r', (error, openedFd) => (error ? rejectOpen(error) : resolveOpen(openedFd)))
|
|
738
|
+
);
|
|
739
|
+
if (cancelled) {
|
|
740
|
+
closeFd();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
bodyPrefix = Buffer.alloc(0);
|
|
744
|
+
bodyPosition = HEADER_SIZE;
|
|
745
|
+
}
|
|
746
|
+
// Re-read the header from the (current) fd: the writer we just waited for finalizes the size
|
|
747
|
+
// in place, and an aborted receive stamps PENDING/ERROR over the same inode (harper-pro#481).
|
|
748
|
+
const header = Buffer.allocUnsafe(HEADER_SIZE);
|
|
749
|
+
const headerBytes = await new Promise<number>((resolveRead, rejectRead) =>
|
|
750
|
+
read(fd, header, 0, HEADER_SIZE, 0, (error, bytesRead) => (error ? rejectRead(error) : resolveRead(bytesRead)))
|
|
751
|
+
);
|
|
752
|
+
if (cancelled || fd == null) return;
|
|
753
|
+
if (headerBytes < HEADER_SIZE) throw new BlobReadError(`Incomplete blob for ${filePath}`, BLOB_CORRUPT_STATUS);
|
|
754
|
+
const headerValue = new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0);
|
|
755
|
+
const type = Number(headerValue >> 48n);
|
|
756
|
+
if (type === ERROR_TYPE || type === PENDING_TYPE) {
|
|
757
|
+
const message = Buffer.allocUnsafe(256);
|
|
758
|
+
const messageBytes = await new Promise<number>((resolveRead) =>
|
|
759
|
+
read(fd, message, 0, message.length, HEADER_SIZE, (error, bytesRead) => resolveRead(error ? 0 : bytesRead))
|
|
760
|
+
);
|
|
761
|
+
throw type === ERROR_TYPE
|
|
762
|
+
? new BlobReadError('Error in blob: ' + message.subarray(0, messageBytes), BLOB_CORRUPT_STATUS)
|
|
763
|
+
: new BlobReadError('Blob pending replication for ' + filePath, BLOB_UNAVAILABLE_STATUS);
|
|
764
|
+
}
|
|
765
|
+
const declaredSize = Number(headerValue & 0xffffffffffffn);
|
|
766
|
+
if (type === UNCOMPRESSED_TYPE) {
|
|
767
|
+
// We sniffed DEFLATE, waited, and the file is now uncompressed: an in-place repair
|
|
768
|
+
// replaced it with a healthy uncompressed body. Report retryable so the caller re-reads
|
|
769
|
+
// the current file through the ordinary path rather than misclassifying it as corrupt.
|
|
770
|
+
throw new BlobReadError(`Blob ${filePath} was replaced while reading; retry`, BLOB_UNAVAILABLE_STATUS);
|
|
771
|
+
}
|
|
772
|
+
if (type !== DEFLATE_TYPE || declaredSize === UNKNOWN_SIZE) {
|
|
773
|
+
// the writer is confirmed done, so a placeholder size (or a header no longer readable as
|
|
774
|
+
// deflate) is a write that never finished — confidently incomplete, not merely in progress
|
|
775
|
+
throw new BlobReadError(`Incomplete blob for ${filePath}`, BLOB_CORRUPT_STATUS);
|
|
776
|
+
}
|
|
777
|
+
if (isFullRead && descriptorSize != null && descriptorSize < UNKNOWN_SIZE && declaredSize !== descriptorSize) {
|
|
778
|
+
throw new BlobReadError(
|
|
779
|
+
`Blob size mismatch for ${filePath}: record descriptor expects ${descriptorSize} bytes, on-disk header reports ${declaredSize}`,
|
|
780
|
+
BLOB_CORRUPT_STATUS
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
if (blob.size !== declaredSize) {
|
|
784
|
+
(blob as any).size = declaredSize;
|
|
785
|
+
if (blob.#onSize) {
|
|
786
|
+
for (const callback of blob.#onSize) callback(declaredSize);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// The read stream takes sole ownership of the descriptor (nulling `fd` leaves closeFd() only
|
|
790
|
+
// the hold to release): an fs stream's worker-thread close(2) races any other user of the fd.
|
|
791
|
+
const source = createReadStream(filePath, { fd, start: bodyPosition, autoClose: true });
|
|
792
|
+
fd = null;
|
|
793
|
+
const inflater = createInflate();
|
|
794
|
+
teardownCompressedRead = () => {
|
|
795
|
+
source.destroy();
|
|
796
|
+
inflater.destroy();
|
|
797
|
+
};
|
|
798
|
+
// pipe() does not forward source errors to its destination; without this a disk fault
|
|
799
|
+
// mid-read would be an unhandled 'error' on the source stream.
|
|
800
|
+
source.on('error', (error) => inflater.destroy(error));
|
|
801
|
+
if (bodyPrefix.length > 0) inflater.write(bodyPrefix);
|
|
802
|
+
source.pipe(inflater);
|
|
803
|
+
const sliceStart = start ?? 0;
|
|
804
|
+
let inflatedTotal = 0; // content offset consumed so far (emitted or discarded)
|
|
805
|
+
try {
|
|
806
|
+
for await (const chunk of inflater as AsyncIterable<Buffer>) {
|
|
807
|
+
const chunkStart = inflatedTotal;
|
|
808
|
+
inflatedTotal += chunk.length;
|
|
809
|
+
if (inflatedTotal > declaredSize) {
|
|
810
|
+
// refused before emitting, so a body that lies about its length cannot stream unbounded output
|
|
811
|
+
throw new BlobReadError(
|
|
812
|
+
`Blob ${filePath} inflates past its declared size of ${declaredSize}`,
|
|
813
|
+
BLOB_CORRUPT_STATUS
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
const emitFrom = Math.max(sliceStart - chunkStart, 0);
|
|
817
|
+
const emitTo = end === undefined ? chunk.length : Math.min(end - chunkStart, chunk.length);
|
|
818
|
+
if (emitTo > emitFrom)
|
|
819
|
+
yield emitFrom === 0 && emitTo === chunk.length ? chunk : chunk.subarray(emitFrom, emitTo);
|
|
820
|
+
if (end !== undefined && inflatedTotal >= end) return; // slice satisfied; stop inflating
|
|
821
|
+
}
|
|
822
|
+
if (inflatedTotal !== declaredSize) {
|
|
823
|
+
// clean deflate end short of the declared size: torn despite the writer being done
|
|
824
|
+
throw new BlobReadError(
|
|
825
|
+
`Incomplete blob for ${filePath}: inflated to ${inflatedTotal} bytes of a declared ${declaredSize}`,
|
|
826
|
+
BLOB_CORRUPT_STATUS
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
} catch (error) {
|
|
830
|
+
if (cancelled) return;
|
|
831
|
+
if (!(error instanceof BlobReadError) && (error as { code?: string }).code?.startsWith?.('Z_')) {
|
|
832
|
+
// the writer is confirmed done, so a zlib failure is real corruption, not an in-progress body
|
|
833
|
+
throw new BlobReadError(
|
|
834
|
+
`Error reading compressed blob for ${filePath}: ${(error as Error).message}`,
|
|
835
|
+
BLOB_CORRUPT_STATUS
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
throw error;
|
|
839
|
+
} finally {
|
|
840
|
+
teardownCompressedRead();
|
|
841
|
+
teardownCompressedRead = undefined;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const pumpCompressedRead = (controller: ReadableStreamDefaultController) =>
|
|
845
|
+
compressedRead.next().then(
|
|
846
|
+
(step) => {
|
|
847
|
+
if (cancelled) return;
|
|
848
|
+
if (step.done) {
|
|
849
|
+
closeFd();
|
|
850
|
+
try {
|
|
851
|
+
controller.close();
|
|
852
|
+
} catch {
|
|
853
|
+
// controller may already be closed
|
|
854
|
+
}
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
controller.enqueue(step.value);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
logger.debug?.('Error enqueuing chunk', error);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
(error: Error) => {
|
|
864
|
+
closeFd();
|
|
865
|
+
if (cancelled) return;
|
|
866
|
+
blob.#onError?.forEach((callback) => callback(error));
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
);
|
|
546
870
|
|
|
547
871
|
return new ReadableStream({
|
|
548
872
|
start() {
|
|
@@ -595,6 +919,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
595
919
|
return new Promise(openFile);
|
|
596
920
|
},
|
|
597
921
|
pull: (controller) => {
|
|
922
|
+
if (compressedRead) return pumpCompressedRead(controller);
|
|
598
923
|
let size = 0;
|
|
599
924
|
let retries = 100;
|
|
600
925
|
// No-progress deadline for the incomplete-content wait below, mirroring the open-retry loop's
|
|
@@ -615,7 +940,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
615
940
|
settled = true;
|
|
616
941
|
closeFd();
|
|
617
942
|
clearTimeout(timer);
|
|
618
|
-
|
|
943
|
+
closeWatcher();
|
|
619
944
|
reject(error);
|
|
620
945
|
blob.#onError?.forEach((callback) => callback(error));
|
|
621
946
|
}
|
|
@@ -663,34 +988,11 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
663
988
|
return onError(new BlobReadError('Blob pending replication for ' + filePath, BLOB_UNAVAILABLE_STATUS));
|
|
664
989
|
}
|
|
665
990
|
if (buffer[1] === DEFLATE_TYPE) {
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
// bytes() resolves asynchronously; the consumer may have cancelled meanwhile.
|
|
672
|
-
// Settle exactly once and route the close through closeFd() so we never touch a
|
|
673
|
-
// reassigned/nulled descriptor (#1457).
|
|
674
|
-
if (settled || cancelled) return resolve();
|
|
675
|
-
settled = true;
|
|
676
|
-
closeFd();
|
|
677
|
-
if (bytes.length > 0) {
|
|
678
|
-
try {
|
|
679
|
-
controller.enqueue(bytes);
|
|
680
|
-
} catch (error) {
|
|
681
|
-
logger.debug?.('Error enqueuing chunk', error);
|
|
682
|
-
return resolve();
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
try {
|
|
686
|
-
controller.close();
|
|
687
|
-
} catch {
|
|
688
|
-
// controller may already be closed
|
|
689
|
-
}
|
|
690
|
-
resolve();
|
|
691
|
-
},
|
|
692
|
-
(error: Error) => onError(error)
|
|
693
|
-
);
|
|
991
|
+
// a deflate body cannot be length-checked or offset-seeked: this and every later
|
|
992
|
+
// pull go through the streaming inflate iterator instead of the raw read loop
|
|
993
|
+
settled = true;
|
|
994
|
+
compressedRead = readCompressedBlob(buffer.subarray(HEADER_SIZE, bytesRead), bytesRead);
|
|
995
|
+
return pumpCompressedRead(controller).then(resolve, reject);
|
|
694
996
|
}
|
|
695
997
|
size = Number(headerValue & 0xffffffffffffn);
|
|
696
998
|
if (size < UNKNOWN_SIZE) {
|
|
@@ -738,10 +1040,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
738
1040
|
);
|
|
739
1041
|
if (updatedSize === UNKNOWN_SIZE) return false;
|
|
740
1042
|
size = updatedSize;
|
|
741
|
-
|
|
742
|
-
watcher.close();
|
|
743
|
-
watcher = null;
|
|
744
|
-
}
|
|
1043
|
+
closeWatcher();
|
|
745
1044
|
// The header reports a known final size but the bytes at `position` have not arrived.
|
|
746
1045
|
// Re-entering readMore() synchronously here busy-spins the worker at ~100% CPU on a
|
|
747
1046
|
// present-but-truncated blob (header rewritten to a self-consistent smaller size, lock
|
|
@@ -763,25 +1062,42 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
763
1062
|
return true;
|
|
764
1063
|
};
|
|
765
1064
|
// the file is not finished being written, watch the file for changes to resume reading
|
|
766
|
-
|
|
767
|
-
watcher =
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1065
|
+
watchTarget ??= resolveWatchTarget(filePath);
|
|
1066
|
+
watcher = watchInProgressFile(filePath, watchTarget, {
|
|
1067
|
+
isLive: (candidate) => watcher === candidate,
|
|
1068
|
+
onChange: () => {
|
|
1069
|
+
closeWatcher();
|
|
771
1070
|
clearTimeout(timer); // clear it
|
|
772
1071
|
readMore(resolve, reject);
|
|
773
|
-
}
|
|
1072
|
+
},
|
|
1073
|
+
onFailure: () => {
|
|
1074
|
+
watcher = null;
|
|
1075
|
+
clearTimeout(timer);
|
|
1076
|
+
timer = setTimeout(() => readMore(resolve, reject), 20).unref();
|
|
1077
|
+
},
|
|
774
1078
|
});
|
|
775
1079
|
// immediately try to read again in case there was a change before we started watching,
|
|
776
1080
|
// readSync should be fine here, the data should be in memory
|
|
777
1081
|
if (readSync(fd, buffer, 0, buffer.length, position) > 0) {
|
|
778
1082
|
// never mind with the watcher, let's read more data
|
|
779
|
-
|
|
780
|
-
watcher.close();
|
|
781
|
-
watcher = null;
|
|
782
|
-
}
|
|
1083
|
+
closeWatcher();
|
|
783
1084
|
readMore(resolve, reject);
|
|
784
1085
|
} else if (!resumeIfWriterFinished()) {
|
|
1086
|
+
if (!watcher) {
|
|
1087
|
+
// Nothing will wake this read, so poll on the deadline resumeIfWriterFinished uses
|
|
1088
|
+
// rather than sitting out the full read timeout and 503-ing a healthy write.
|
|
1089
|
+
if (Date.now() >= incompleteDeadline) {
|
|
1090
|
+
onError(
|
|
1091
|
+
new BlobReadError(
|
|
1092
|
+
`File read timed out reading from ${filePath}, read ${totalContentRead} bytes, but size is supposed to be ${size} bytes`,
|
|
1093
|
+
BLOB_UNAVAILABLE_STATUS
|
|
1094
|
+
)
|
|
1095
|
+
);
|
|
1096
|
+
} else {
|
|
1097
|
+
timer = setTimeout(() => readMore(resolve, reject), 20).unref();
|
|
1098
|
+
}
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
785
1101
|
// set a timer for the watcher too. A write that stalls past the bound returns a
|
|
786
1102
|
// prompt 503 (retryable) instead of holding the connection for the full 60s (#1423).
|
|
787
1103
|
timer = setTimeout(() => {
|
|
@@ -868,10 +1184,11 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
868
1184
|
},
|
|
869
1185
|
cancel() {
|
|
870
1186
|
cancelled = true;
|
|
1187
|
+
teardownCompressedRead?.();
|
|
871
1188
|
closeFd(); // releases the hold, including when cancelled before any open succeeded
|
|
872
1189
|
clearTimeout(timer);
|
|
873
1190
|
clearTimeout(openTimer);
|
|
874
|
-
|
|
1191
|
+
closeWatcher();
|
|
875
1192
|
},
|
|
876
1193
|
});
|
|
877
1194
|
function checkIfIsBeingWritten() {
|
|
@@ -892,6 +1209,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
|
|
|
892
1209
|
if (sourceStorageInfo?.fileId) {
|
|
893
1210
|
const slicedStorageInfo = {
|
|
894
1211
|
...sourceStorageInfo,
|
|
1212
|
+
fileState: (sourceStorageInfo.fileState ??= {}),
|
|
895
1213
|
start,
|
|
896
1214
|
end,
|
|
897
1215
|
};
|
|
@@ -926,7 +1244,9 @@ const RECLAMATION_AGE_CAP = 1_200_000;
|
|
|
926
1244
|
const HELD_RECHECK_INTERVAL = 1000;
|
|
927
1245
|
|
|
928
1246
|
interface PendingReclamation {
|
|
929
|
-
|
|
1247
|
+
blobs: WeakRef<Blob>[];
|
|
1248
|
+
seenBlobs: WeakSet<Blob>;
|
|
1249
|
+
fileInfo: BlobFileInfo;
|
|
930
1250
|
deadline: number;
|
|
931
1251
|
enqueuedAt: number;
|
|
932
1252
|
supersededAt: number;
|
|
@@ -1056,7 +1376,7 @@ export function holdBlobFile(blob: Blob): (() => void) | null {
|
|
|
1056
1376
|
* Whether a record version referencing this file again was written since it was queued — by any
|
|
1057
1377
|
* worker. Reading clears it, so the next supersession starts from a clean slate.
|
|
1058
1378
|
*/
|
|
1059
|
-
function consumeRereferenced(storageInfo:
|
|
1379
|
+
function consumeRereferenced(storageInfo: BlobFileInfo | undefined): boolean {
|
|
1060
1380
|
const store = storageInfo?.store;
|
|
1061
1381
|
const fileId = storageInfo?.fileId;
|
|
1062
1382
|
if (!store || !fileId) return false;
|
|
@@ -1077,14 +1397,14 @@ function consumeRereferenced(storageInfo: StorageInfo | undefined): boolean {
|
|
|
1077
1397
|
* the retention window. The second of granularity is padded rather than rounded: a snapshot opened
|
|
1078
1398
|
* in the same second as the supersession is treated as possibly older than it.
|
|
1079
1399
|
*/
|
|
1080
|
-
function snapshotStillSees(storageInfo:
|
|
1400
|
+
function snapshotStillSees(storageInfo: BlobFileInfo | undefined, supersededAt: number): boolean {
|
|
1081
1401
|
const oldestSnapshotSeconds = storageInfo?.store?.getOldestSnapshotTimestamp?.();
|
|
1082
1402
|
if (!oldestSnapshotSeconds) return false;
|
|
1083
1403
|
return oldestSnapshotSeconds * 1000 <= supersededAt + 1000;
|
|
1084
1404
|
}
|
|
1085
1405
|
|
|
1086
1406
|
/** Undo a reclaimer's claim once its unlink has landed, leaving the slot usable again. */
|
|
1087
|
-
function releaseReclaimClaim(storageInfo:
|
|
1407
|
+
function releaseReclaimClaim(storageInfo: BlobFileInfo | undefined): void {
|
|
1088
1408
|
const store = storageInfo?.store;
|
|
1089
1409
|
const fileId = storageInfo?.fileId;
|
|
1090
1410
|
if (!store || !fileId) return;
|
|
@@ -1096,7 +1416,7 @@ function releaseReclaimClaim(storageInfo: StorageInfo | undefined): void {
|
|
|
1096
1416
|
* Whether anything is still using the file. `claim` is for the reclaimer: it atomically takes the
|
|
1097
1417
|
* count from 0 to RECLAIMING so a hold cannot be acquired between this check and the unlink.
|
|
1098
1418
|
*/
|
|
1099
|
-
function isBlobHeld(storageInfo:
|
|
1419
|
+
function isBlobHeld(storageInfo: BlobFileInfo | undefined, claim = false): boolean {
|
|
1100
1420
|
const store = storageInfo?.store;
|
|
1101
1421
|
const fileId = storageInfo?.fileId;
|
|
1102
1422
|
if (!store || !fileId) return false;
|
|
@@ -1171,8 +1491,23 @@ export function deleteBlob(blob: Blob): void {
|
|
|
1171
1491
|
if (state) Atomics.store(state.table, state.slot + REREFERENCED, 0);
|
|
1172
1492
|
}
|
|
1173
1493
|
// Reusing the queued entry when two writes supersede the same file keeps the age cap measuring
|
|
1174
|
-
// from the first supersession.
|
|
1175
|
-
const pending = pendingReclamation.get(filePath) ?? {
|
|
1494
|
+
// from the first supersession and tracks every live blob instance that carries the condemned fileId.
|
|
1495
|
+
const pending = pendingReclamation.get(filePath) ?? {
|
|
1496
|
+
blobs: [],
|
|
1497
|
+
seenBlobs: new WeakSet<Blob>(),
|
|
1498
|
+
fileInfo: { store: storageInfo?.store, fileId: storageInfo?.fileId },
|
|
1499
|
+
deadline: 0,
|
|
1500
|
+
enqueuedAt: now,
|
|
1501
|
+
supersededAt: now,
|
|
1502
|
+
};
|
|
1503
|
+
if (!pending.seenBlobs.has(blob)) {
|
|
1504
|
+
pending.seenBlobs.add(blob);
|
|
1505
|
+
pending.blobs.push(new WeakRef(blob));
|
|
1506
|
+
}
|
|
1507
|
+
if (pending.unlinking) {
|
|
1508
|
+
if (storageInfo) discardStorage(storageInfo);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1176
1511
|
scheduleReclamation(enqueue(filePath, pending, Math.max(pending.deadline, now + getReclamationDelay())));
|
|
1177
1512
|
}
|
|
1178
1513
|
|
|
@@ -1219,7 +1554,7 @@ function runReclamation(): void {
|
|
|
1219
1554
|
earliest = pending.deadline;
|
|
1220
1555
|
break; // insertion order is deadline order; nothing behind this entry is due
|
|
1221
1556
|
}
|
|
1222
|
-
const storageInfo =
|
|
1557
|
+
const storageInfo = pending.fileInfo;
|
|
1223
1558
|
const expired = now - pending.enqueuedAt >= ageCap;
|
|
1224
1559
|
let held: boolean;
|
|
1225
1560
|
try {
|
|
@@ -1276,6 +1611,14 @@ function runReclamation(): void {
|
|
|
1276
1611
|
// Keep the entry until the unlink lands so a concurrent re-reference can tell that the file is
|
|
1277
1612
|
// already going away instead of silently adopting a doomed path.
|
|
1278
1613
|
pending.unlinking = true;
|
|
1614
|
+
// Once reclamation has claimed this file, a later write must not preserve its soon-to-be-deleted
|
|
1615
|
+
// fileId. The retention window above still permits legitimate re-references before this point.
|
|
1616
|
+
for (const blobRef of pending.blobs) {
|
|
1617
|
+
const blob = blobRef.deref();
|
|
1618
|
+
if (!blob) continue;
|
|
1619
|
+
const instanceStorageInfo = storageInfoForBlob.get(blob);
|
|
1620
|
+
if (instanceStorageInfo) discardStorage(instanceStorageInfo);
|
|
1621
|
+
}
|
|
1279
1622
|
unlink(filePath, (error) => {
|
|
1280
1623
|
pendingReclamation.delete(filePath);
|
|
1281
1624
|
if (pendingReclamation.size === 0) queueTailDeadline = 0;
|
|
@@ -1302,6 +1645,81 @@ export type BlobCreationOptions = {
|
|
|
1302
1645
|
size?: number; // the size of the data, if known ahead of time
|
|
1303
1646
|
saveBeforeCommit?: boolean; // save the blob before the transaction is committed
|
|
1304
1647
|
};
|
|
1648
|
+
/**
|
|
1649
|
+
* `StorageInfo.contentBuffer` must always hold a `Buffer`: `Buffer.prototype.toString()` decodes
|
|
1650
|
+
* UTF-8, while the inherited `Uint8Array.prototype.toString()` joins the byte values with commas.
|
|
1651
|
+
*/
|
|
1652
|
+
function asBuffer(bytes: Uint8Array): Buffer {
|
|
1653
|
+
return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
type BlobCompressionEntry = false | { codec?: 'deflate'; threshold?: number };
|
|
1657
|
+
const DEFAULT_COMPRESSION_THRESHOLD = FILE_STORAGE_THRESHOLD;
|
|
1658
|
+
/**
|
|
1659
|
+
* Already-compressed containers (the deployment recorder writes `application/gzip`). An operator entry
|
|
1660
|
+
* for the same key overrides these; a broader operator wildcard does not — specificity beats origin.
|
|
1661
|
+
*/
|
|
1662
|
+
const BUILT_IN_COMPRESSION_ENTRIES: Record<string, BlobCompressionEntry> = {
|
|
1663
|
+
'application/gzip': false,
|
|
1664
|
+
'application/zip': false,
|
|
1665
|
+
'application/zstd': false,
|
|
1666
|
+
'image/*': false,
|
|
1667
|
+
'video/*': false,
|
|
1668
|
+
'audio/*': false,
|
|
1669
|
+
};
|
|
1670
|
+
/** The matcher works on the canonical lowercase base media type, with any `;` parameters stripped. */
|
|
1671
|
+
function normalizeContentType(type: string | undefined): string {
|
|
1672
|
+
if (!type) return '';
|
|
1673
|
+
const parametersStart = type.indexOf(';');
|
|
1674
|
+
return (parametersStart === -1 ? type : type.slice(0, parametersStart)).trim().toLowerCase();
|
|
1675
|
+
}
|
|
1676
|
+
// The normalized policy map is rebuilt only when the configured value object changes identity, so
|
|
1677
|
+
// the per-save cost is one config lookup and a reference compare.
|
|
1678
|
+
let compressionPolicyForConfig: unknown;
|
|
1679
|
+
let compressionPolicy: Map<string, BlobCompressionEntry> | null = null;
|
|
1680
|
+
let invalidCompressionConfigLogged = false;
|
|
1681
|
+
function getBlobCompressionPolicy(): Map<string, BlobCompressionEntry> | null {
|
|
1682
|
+
const configured = envGet(CONFIG_PARAMS.STORAGE_BLOBS_COMPRESSION);
|
|
1683
|
+
if (configured === compressionPolicyForConfig) return compressionPolicy;
|
|
1684
|
+
compressionPolicyForConfig = configured;
|
|
1685
|
+
compressionPolicy = null;
|
|
1686
|
+
if (configured == null) return null;
|
|
1687
|
+
if (typeof configured !== 'object' || Array.isArray(configured)) {
|
|
1688
|
+
if (!invalidCompressionConfigLogged) {
|
|
1689
|
+
invalidCompressionConfigLogged = true;
|
|
1690
|
+
logger.warn?.('Ignoring invalid storage.blobs.compression value; expected a map of content types');
|
|
1691
|
+
}
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
const policy = new Map<string, BlobCompressionEntry>(Object.entries(BUILT_IN_COMPRESSION_ENTRIES));
|
|
1695
|
+
for (const [key, entry] of Object.entries(configured as Record<string, BlobCompressionEntry>)) {
|
|
1696
|
+
policy.set(normalizeContentType(key) || 'default', entry);
|
|
1697
|
+
}
|
|
1698
|
+
compressionPolicy = policy;
|
|
1699
|
+
return policy;
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Whether a new blob of `type` and (uncompressed) `size` should be deflate-compressed on disk,
|
|
1703
|
+
* per the opt-in `storage.blobs.compression` map. Absent config means never. Matching precedence
|
|
1704
|
+
* is exact type, then `type/*`, then `default`. An unknown size never compresses: the threshold
|
|
1705
|
+
* cannot be evaluated, and compressing a small streamed write under a large configured threshold
|
|
1706
|
+
* would violate the operator's limit.
|
|
1707
|
+
*/
|
|
1708
|
+
export function resolveBlobCompression(type: string | undefined, size: number | undefined): boolean {
|
|
1709
|
+
const policy = getBlobCompressionPolicy();
|
|
1710
|
+
if (!policy) return false;
|
|
1711
|
+
const normalized = normalizeContentType(type);
|
|
1712
|
+
let entry = policy.get(normalized);
|
|
1713
|
+
if (entry === undefined && normalized) {
|
|
1714
|
+
const slash = normalized.indexOf('/');
|
|
1715
|
+
if (slash > 0) entry = policy.get(normalized.slice(0, slash + 1) + '*');
|
|
1716
|
+
}
|
|
1717
|
+
entry ??= policy.get('default');
|
|
1718
|
+
if (!entry) return false;
|
|
1719
|
+
if (entry.codec !== undefined && entry.codec !== 'deflate') return false; // enforced by config validation; fail safe
|
|
1720
|
+
if (size === undefined) return false;
|
|
1721
|
+
return size >= (entry.threshold ?? DEFAULT_COMPRESSION_THRESHOLD);
|
|
1722
|
+
}
|
|
1305
1723
|
/**
|
|
1306
1724
|
* Create a blob from a readable stream or a buffer by creating a file in the blob storage path with a new unique internal id, that
|
|
1307
1725
|
* can be saved/stored.
|
|
@@ -1321,7 +1739,7 @@ export function createBlob(
|
|
|
1321
1739
|
storageInfoForBlob.set(blob, storageInfo);
|
|
1322
1740
|
if (source instanceof Uint8Array) {
|
|
1323
1741
|
blob.size = source.length;
|
|
1324
|
-
storageInfo.contentBuffer = source;
|
|
1742
|
+
storageInfo.contentBuffer = asBuffer(source);
|
|
1325
1743
|
} else if (source instanceof Readable) {
|
|
1326
1744
|
storageInfo.source = source;
|
|
1327
1745
|
} else if (typeof source === 'string') storageInfo.contentBuffer = Buffer.from(source);
|
|
@@ -1332,6 +1750,25 @@ export function createBlob(
|
|
|
1332
1750
|
}
|
|
1333
1751
|
_assignPackageExport('createBlob', createBlob);
|
|
1334
1752
|
|
|
1753
|
+
/**
|
|
1754
|
+
* Create a blob whose source stream carries the STORED representation — an already-deflated body —
|
|
1755
|
+
* rather than content bytes, so a replication receiver can land a peer's compressed blob without
|
|
1756
|
+
* recompressing it. `size` is the uncompressed content length (it goes in the header; a raw
|
|
1757
|
+
* compressed source cannot derive it) and the body is verified on write: it must be a well-formed
|
|
1758
|
+
* deflate stream inflating to exactly `size` bytes, or the save rejects (see
|
|
1759
|
+
* createStoredDeflateVerifier). Internal to replication on purpose — not part of BlobCreationOptions
|
|
1760
|
+
* and not exported to components.
|
|
1761
|
+
*/
|
|
1762
|
+
export function createBlobFromStoredBody(
|
|
1763
|
+
source: NodeJS.ReadableStream,
|
|
1764
|
+
options: { type?: string; size: number; codec: 'deflate' }
|
|
1765
|
+
): Blob {
|
|
1766
|
+
if (options.codec !== 'deflate') throw new Error(`Unsupported stored blob codec: ${options.codec}`);
|
|
1767
|
+
const blob = createBlob(source, { type: options.type, size: options.size });
|
|
1768
|
+
storageInfoForBlob.get(blob).storedCodec = options.codec;
|
|
1769
|
+
return blob;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1335
1772
|
// When set (during a migration via encodeBlobsWithFilePath), saveBlob pushes the in-flight
|
|
1336
1773
|
// writeBlob save promise here so the migration can await every blob's durable write before
|
|
1337
1774
|
// declaring the database done. Without this, the migration's `await targetDbi.put(...)`
|
|
@@ -1345,6 +1782,15 @@ export function saveBlob(blob: FileBackedBlob, deleteOnFailure = false) {
|
|
|
1345
1782
|
storageInfo = { storageIndex: 0, fileId: null, store: currentStore };
|
|
1346
1783
|
storageInfoForBlob.set(blob, storageInfo);
|
|
1347
1784
|
} else {
|
|
1785
|
+
if (storageInfo.fileState?.discarded) {
|
|
1786
|
+
// The file this blob was saved to has been deleted (an aborted/skipped write's cleanup, or an
|
|
1787
|
+
// explicit delete). Re-storing it would commit a reference to a file that no longer exists —
|
|
1788
|
+
// a permanently unreadable record, and for a replicated one a blob the peer can never fetch.
|
|
1789
|
+
// Fail here, where the cause is still known, instead of at the eventual read (issue #2062).
|
|
1790
|
+
throw new Error(
|
|
1791
|
+
'Blob was discarded (its file was deleted by an aborted or superseded write) and can no longer be stored; the data must be re-supplied'
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1348
1794
|
if (storageInfo.fileId) return storageInfo; // if there is any file id, we are already saving and can return the info
|
|
1349
1795
|
storageInfo.store = currentStore;
|
|
1350
1796
|
}
|
|
@@ -1356,6 +1802,13 @@ export function saveBlob(blob: FileBackedBlob, deleteOnFailure = false) {
|
|
|
1356
1802
|
}
|
|
1357
1803
|
return storageInfo; // nothing more to do if it supposed to be saved in the record
|
|
1358
1804
|
}
|
|
1805
|
+
// The policy resolves here, not in createBlob, because the HTTP-upload path never sees creation
|
|
1806
|
+
// options — every local write funnels through saveBlob. An explicit `compress` option (either
|
|
1807
|
+
// value) and a pre-compressed source both take precedence.
|
|
1808
|
+
if (storageInfo.compress === undefined && !storageInfo.storedCodec) {
|
|
1809
|
+
const size = (blob as FileBackedBlob).size ?? storageInfo.contentBuffer?.length;
|
|
1810
|
+
if (resolveBlobCompression(blob.type, size)) storageInfo.compress = true;
|
|
1811
|
+
}
|
|
1359
1812
|
generateFilePath(storageInfo);
|
|
1360
1813
|
if (storageInfo.source) writeBlobWithStream(blob as any, storageInfo.source, storageInfo);
|
|
1361
1814
|
else if (storageInfo.contentBuffer) writeBlobWithBuffer(blob as any, storageInfo);
|
|
@@ -1431,6 +1884,87 @@ export function shouldDestroyIdleBlobSource(paused: boolean, bytesWritten: numbe
|
|
|
1431
1884
|
return !(paused && bytesWritten > lastProgressBytes);
|
|
1432
1885
|
}
|
|
1433
1886
|
|
|
1887
|
+
/**
|
|
1888
|
+
* Pass-through for a pre-compressed (stored deflate) blob body that concurrently inflates what it
|
|
1889
|
+
* forwards and fails the pipeline unless the body is a well-formed deflate stream inflating to
|
|
1890
|
+
* exactly `expectedSize` bytes. This is what lets a raw peer-preserved body land under a finalized
|
|
1891
|
+
* DEFLATE header without trusting the peer: a truncated, corrupt, oversized (bomb), or trailing-
|
|
1892
|
+
* garbage body rejects the save instead of being published.
|
|
1893
|
+
*/
|
|
1894
|
+
export function createStoredDeflateVerifier(expectedSize: number, fileId: string): Transform {
|
|
1895
|
+
const inflater = createInflate();
|
|
1896
|
+
let inflatedLength = 0;
|
|
1897
|
+
let failure: Error | undefined;
|
|
1898
|
+
// The in-flight transform() callback, kept so a failure can settle it directly. A mid-write
|
|
1899
|
+
// zlib data error (a corrupt, not merely truncated, body) fires 'error'/'close' but NEVER invokes
|
|
1900
|
+
// the pending `inflater.write` callback, so a transform that waited only on that callback would
|
|
1901
|
+
// stall forever — hanging the save pipeline and leaking the blob write lock. settlePending() is
|
|
1902
|
+
// the escape: whichever of the write callback or the failure runs first clears it; the other is a
|
|
1903
|
+
// no-op, so callback() is still called exactly once per chunk.
|
|
1904
|
+
let pending: { callback: (error?: Error | null, data?: Buffer) => void; chunk: Buffer } | undefined;
|
|
1905
|
+
const settlePending = () => {
|
|
1906
|
+
if (!pending) return;
|
|
1907
|
+
const { callback, chunk } = pending;
|
|
1908
|
+
pending = undefined;
|
|
1909
|
+
callback(failure, failure ? undefined : chunk);
|
|
1910
|
+
};
|
|
1911
|
+
const fail = (error: Error) => {
|
|
1912
|
+
failure ??= error;
|
|
1913
|
+
inflater.destroy();
|
|
1914
|
+
settlePending();
|
|
1915
|
+
};
|
|
1916
|
+
inflater.on('data', (inflated: Buffer) => {
|
|
1917
|
+
inflatedLength += inflated.length;
|
|
1918
|
+
if (inflatedLength > expectedSize)
|
|
1919
|
+
fail(new Error(`Pre-compressed blob body for ${fileId} inflates past its declared size of ${expectedSize}`));
|
|
1920
|
+
});
|
|
1921
|
+
inflater.on('error', (error) =>
|
|
1922
|
+
fail(new Error(`Pre-compressed blob body for ${fileId} is not a valid deflate stream`, { cause: error }))
|
|
1923
|
+
);
|
|
1924
|
+
// Trailing data past the final deflate block is detected best-effort (zlib may acknowledge a write
|
|
1925
|
+
// before emitting the early 'end' it triggers); readers ignore trailing bytes, so the residue is
|
|
1926
|
+
// only wasted disk, while truncation, wrong length, invalid stream, and oversize are deterministic.
|
|
1927
|
+
const trailingData = () =>
|
|
1928
|
+
new Error(`Pre-compressed blob body for ${fileId} continues past the end of its deflate stream`);
|
|
1929
|
+
let sawDeflateEnd = false;
|
|
1930
|
+
inflater.on('end', () => {
|
|
1931
|
+
if (!inflater.writableEnded) fail(trailingData());
|
|
1932
|
+
sawDeflateEnd = true;
|
|
1933
|
+
});
|
|
1934
|
+
return new Transform({
|
|
1935
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
1936
|
+
if (!failure && sawDeflateEnd) fail(trailingData());
|
|
1937
|
+
if (failure) return callback(failure);
|
|
1938
|
+
// Forward the chunk only once zlib has consumed it: this is the backpressure coupling and
|
|
1939
|
+
// what keeps the end/overflow/error detections ordered ahead of the next chunk. If zlib
|
|
1940
|
+
// data-errors while consuming this chunk it drops the write callback, so fail()/settlePending()
|
|
1941
|
+
// settles this transform instead (see the `pending` note above).
|
|
1942
|
+
pending = { callback, chunk };
|
|
1943
|
+
inflater.write(chunk, (writeError) => {
|
|
1944
|
+
if (!pending) return; // already settled by a failure; do not call callback twice
|
|
1945
|
+
pending = undefined;
|
|
1946
|
+
const error = failure ?? (writeError as Error | undefined);
|
|
1947
|
+
callback(error, error ? undefined : chunk);
|
|
1948
|
+
});
|
|
1949
|
+
},
|
|
1950
|
+
flush(callback) {
|
|
1951
|
+
if (failure) return callback(failure);
|
|
1952
|
+
inflater.on('close', () => {
|
|
1953
|
+
if (!failure && inflatedLength !== expectedSize)
|
|
1954
|
+
failure = new Error(
|
|
1955
|
+
`Pre-compressed blob body for ${fileId} inflated to ${inflatedLength} bytes; expected ${expectedSize}`
|
|
1956
|
+
);
|
|
1957
|
+
callback(failure);
|
|
1958
|
+
});
|
|
1959
|
+
inflater.end();
|
|
1960
|
+
},
|
|
1961
|
+
destroy(error, callback) {
|
|
1962
|
+
inflater.destroy();
|
|
1963
|
+
callback(error);
|
|
1964
|
+
},
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1434
1968
|
function writeBlobWithStream(
|
|
1435
1969
|
blob: Blob,
|
|
1436
1970
|
stream: Readable,
|
|
@@ -1444,7 +1978,7 @@ function writeBlobWithStream(
|
|
|
1444
1978
|
const repairTargetPath = options?.repairTargetPath;
|
|
1445
1979
|
const repairing = repairTargetPath !== undefined;
|
|
1446
1980
|
const repairTempLockKey = options?.repairTempLockKey;
|
|
1447
|
-
const { filePath, fileId, store, compress, flush } = storageInfo;
|
|
1981
|
+
const { filePath, fileId, store, compress, storedCodec, flush } = storageInfo;
|
|
1448
1982
|
storageInfo.saving = new Promise((resolve, reject) => {
|
|
1449
1983
|
const lockKey = fileId + ':blob';
|
|
1450
1984
|
let lockAcquired = repairing;
|
|
@@ -1489,7 +2023,7 @@ function writeBlobWithStream(
|
|
|
1489
2023
|
let headerValue = BigInt(size);
|
|
1490
2024
|
const header = new Uint8Array(HEADER_SIZE);
|
|
1491
2025
|
const headerView = new DataView(header.buffer);
|
|
1492
|
-
headerValue |= BigInt(compress ? DEFLATE_TYPE : UNCOMPRESSED_TYPE) << 48n;
|
|
2026
|
+
headerValue |= BigInt(compress || storedCodec ? DEFLATE_TYPE : UNCOMPRESSED_TYPE) << 48n;
|
|
1493
2027
|
headerView.setBigInt64(0, headerValue);
|
|
1494
2028
|
return header;
|
|
1495
2029
|
}
|
|
@@ -1528,8 +2062,8 @@ function writeBlobWithStream(
|
|
|
1528
2062
|
// half-replicated blob returns 503 (retry) instead of 500 (confidently incomplete → the peer
|
|
1529
2063
|
// advances its resume cursor past it = silent loss, harper-pro#481). Hold the write lock until the
|
|
1530
2064
|
// marker is durable so no concurrent read/send observes the bare partial file (lock-free + short =
|
|
1531
|
-
// classified 500 = the very loss this prevents). The re-stream
|
|
1532
|
-
//
|
|
2065
|
+
// classified 500 = the very loss this prevents). The re-stream builds a fresh blob and so takes a
|
|
2066
|
+
// new file id, leaving this stub for orphan GC; a terminal give-up unlinks it (→ 404). Build
|
|
1533
2067
|
// the header directly rather than via createHeader so its compress-type OR can't collide with the
|
|
1534
2068
|
// PENDING type bits.
|
|
1535
2069
|
// Bounded so a `writeFile` that never calls back cannot leave `saving` un-settled for the
|
|
@@ -1633,6 +2167,24 @@ function writeBlobWithStream(
|
|
|
1633
2167
|
} else void replaceTarget();
|
|
1634
2168
|
return;
|
|
1635
2169
|
}
|
|
2170
|
+
if (compressedStream && (blob as { size?: number }).size !== compressedStream.bytesWritten) {
|
|
2171
|
+
// A known-size compressed write stamped the declared size into the header up front, but
|
|
2172
|
+
// the source ended with a different content length. Unlike the uncompressed path — whose
|
|
2173
|
+
// short body reads as retryable-incomplete (503) — this file would inflate to the wrong
|
|
2174
|
+
// size and every reader would reject it as permanently corrupt (500). Fail the save so the
|
|
2175
|
+
// record never commits a reference to an unreadable file (matches the storedCodec verifier
|
|
2176
|
+
// and the repair size check); the unreferenced file is left for orphan GC. The unknown-size
|
|
2177
|
+
// path syncs blob.size to bytesWritten before reaching here, so it never trips this.
|
|
2178
|
+
store.unlock(lockKey);
|
|
2179
|
+
reject(
|
|
2180
|
+
new Error(
|
|
2181
|
+
`Blob ${fileId} deflated ${compressedStream.bytesWritten} bytes but its header declares ${(blob as { size?: number }).size}`
|
|
2182
|
+
)
|
|
2183
|
+
);
|
|
2184
|
+
close(fd);
|
|
2185
|
+
(writeStream as any).fd = null;
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
1636
2188
|
store.unlock(lockKey);
|
|
1637
2189
|
if (flush) {
|
|
1638
2190
|
// we just use fdatasync because we really aren't that concerned with flushing file metadata
|
|
@@ -1650,6 +2202,12 @@ function writeBlobWithStream(
|
|
|
1650
2202
|
}
|
|
1651
2203
|
}
|
|
1652
2204
|
try {
|
|
2205
|
+
if (storedCodec && blob.size === undefined) {
|
|
2206
|
+
// The header must carry the uncompressed length, and it cannot be derived from a raw
|
|
2207
|
+
// compressed source (bytesWritten would be the compressed count). Refuse before any file
|
|
2208
|
+
// exists at the path so the failure reads as a cleanly absent blob, not a torn one.
|
|
2209
|
+
throw new Error(`Cannot store a pre-compressed blob without a known uncompressed size (fileId=${fileId})`);
|
|
2210
|
+
}
|
|
1653
2211
|
if (!lockAcquired) {
|
|
1654
2212
|
if (!store.tryLock(lockKey)) throw new Error(`Unable to get lock for blob file ${fileId}`);
|
|
1655
2213
|
lockAcquired = true;
|
|
@@ -1677,7 +2235,13 @@ function writeBlobWithStream(
|
|
|
1677
2235
|
stream.on('resume', armIdleTimer);
|
|
1678
2236
|
armIdleTimer();
|
|
1679
2237
|
}
|
|
1680
|
-
if (
|
|
2238
|
+
if (storedCodec) {
|
|
2239
|
+
// The source bytes are already the stored deflate representation (a peer preserving its
|
|
2240
|
+
// codec). Verify — not just copy — so a truncated or forged body can never land under a
|
|
2241
|
+
// finalized DEFLATE header: the verifier inflates the pass-through bytes concurrently and
|
|
2242
|
+
// fails the pipeline unless they inflate to exactly the declared uncompressed size.
|
|
2243
|
+
pipeline(stream, createStoredDeflateVerifier(blob.size as number, fileId), writeStream, finished);
|
|
2244
|
+
} else if (compress) {
|
|
1681
2245
|
if (!wroteSize) writeStream.write(COMPRESS_HEADER);
|
|
1682
2246
|
compressedStream = createDeflate();
|
|
1683
2247
|
pipeline(stream, compressedStream, writeStream, finished);
|
|
@@ -1724,9 +2288,11 @@ export function getFilePathForBlob(blob: FileBackedBlob): string {
|
|
|
1724
2288
|
* Repairs a damaged file-backed blob under its existing fileId. The damage check and atomic file
|
|
1725
2289
|
* replacement share the blob lock, so a failed repair never modifies the referenced file. Callers
|
|
1726
2290
|
* must establish an exact record identity tie (same version and source node) and positional blob
|
|
1727
|
-
* pairing before calling
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
2291
|
+
* pairing before calling, and classify the file with `blobFileMissingOrIncompleteAsync` first — a
|
|
2292
|
+
* compressed body's damage is only provable by inflating it, which the locked recheck here will
|
|
2293
|
+
* not do. The received byte count must match the reported source size and, when present, the
|
|
2294
|
+
* stored descriptor. On a synchronous `undefined`, no repair started and `source` remains owned
|
|
2295
|
+
* by the caller.
|
|
1730
2296
|
*/
|
|
1731
2297
|
export function repairBlobFile(
|
|
1732
2298
|
blob: Blob,
|
|
@@ -1789,7 +2355,9 @@ export function repairBlobFile(
|
|
|
1789
2355
|
filePath: repairFilePath,
|
|
1790
2356
|
saving: undefined,
|
|
1791
2357
|
flush: true,
|
|
2358
|
+
// the repair source is inflated content; its size verification counts uncompressed bytes
|
|
1792
2359
|
compress: false,
|
|
2360
|
+
storedCodec: undefined,
|
|
1793
2361
|
};
|
|
1794
2362
|
writeBlobWithStream(blob as any, source, repairStorageInfo, {
|
|
1795
2363
|
expectedSize,
|
|
@@ -1813,6 +2381,89 @@ export function repairBlobFile(
|
|
|
1813
2381
|
}
|
|
1814
2382
|
}
|
|
1815
2383
|
|
|
2384
|
+
/** Fail-closed storage and descriptor errors that must abort the capture instead of producing an unverified backup. */
|
|
2385
|
+
const SYSTEMIC_IO_ERRORS = new Set(['EMFILE', 'ENFILE', 'ENOSPC', 'EIO', 'EROFS']);
|
|
2386
|
+
|
|
2387
|
+
export function isSystemicIoError(error: unknown): boolean {
|
|
2388
|
+
return SYSTEMIC_IO_ERRORS.has((error as { code?: string })?.code ?? '');
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* How a consumer that captures a blob root (backup snapshot, backup archive) should treat one file.
|
|
2393
|
+
* `skip` is not a blob to capture at all; `pending` and `gone` require markers; `capture` is taken as-is.
|
|
2394
|
+
* `capture` means settled as far as the path can show, which is short of a guarantee: a known-size write
|
|
2395
|
+
* that has landed every byte is indistinguishable here from a finished one, and if it then aborts, the
|
|
2396
|
+
* PENDING stamp rewrites that inode in place, truncating any same-filesystem hard link taken from it.
|
|
2397
|
+
* Telling the two apart needs the blob write lock, which is keyed by file id and unreachable from a walk.
|
|
2398
|
+
*/
|
|
2399
|
+
export async function classifyBlobFileForCapture(filePath: string): Promise<BlobCaptureDisposition> {
|
|
2400
|
+
if (filePath.endsWith(BLOB_REPAIR_SUFFIX)) return 'skip';
|
|
2401
|
+
let header: Buffer;
|
|
2402
|
+
let fileSize: number;
|
|
2403
|
+
let handle: FileHandle;
|
|
2404
|
+
try {
|
|
2405
|
+
handle = await openFile(filePath, 'r');
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
// Reclamation can unlink a superseded blob after the engine checkpoint but before this walk. The
|
|
2408
|
+
// checkpointed record may still reference it, so reserve the id. Treat local absence as terminal
|
|
2409
|
+
// rather than making every read wait for repair; this gives up peer repair of that checkpointed version.
|
|
2410
|
+
if ((error as { code?: string }).code === 'ENOENT') return 'gone';
|
|
2411
|
+
throw error;
|
|
2412
|
+
}
|
|
2413
|
+
try {
|
|
2414
|
+
fileSize = (await handle.stat()).size;
|
|
2415
|
+
header = Buffer.alloc(HEADER_SIZE);
|
|
2416
|
+
const { bytesRead } = await handle.read(header, 0, HEADER_SIZE, 0);
|
|
2417
|
+
header = header.subarray(0, bytesRead);
|
|
2418
|
+
} finally {
|
|
2419
|
+
await handle.close();
|
|
2420
|
+
}
|
|
2421
|
+
if (blobHeaderIsAbortMarker(header)) return 'capture';
|
|
2422
|
+
if (blobHeaderIndicatesIncomplete(header, fileSize)) return 'pending';
|
|
2423
|
+
// A deflate header records the *uncompressed* length, so the check above compares lengths only for
|
|
2424
|
+
// UNCOMPRESSED_TYPE and a short compressed body reaches here looking whole. Both producers of one
|
|
2425
|
+
// need this: saveBlob stamps a known size before the first compressed byte, so a live write is
|
|
2426
|
+
// invisible above and caught only here, and an unclean shutdown can leave a torn body the
|
|
2427
|
+
// asynchronous repair sweep has not reached yet. Compression is opt-in via storage.blobs.compression
|
|
2428
|
+
// (off by default): on a corpus with it disabled this branch is never taken, but where an operator
|
|
2429
|
+
// enables it each completeness check fully inflates the compressed body it reaches.
|
|
2430
|
+
if (header[1] !== DEFLATE_TYPE) return 'capture';
|
|
2431
|
+
const uncompressedSize = Number(
|
|
2432
|
+
new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0) & 0xffffffffffffn
|
|
2433
|
+
);
|
|
2434
|
+
return (await inflatesToExactly(filePath, uncompressedSize)) ? 'capture' : 'pending';
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Whether the header is a deliberate abort marker rather than content. Written once and never
|
|
2439
|
+
* rewritten, so a consumer sharing blob inodes can keep them: dropping a PENDING marker downgrades a
|
|
2440
|
+
* retryable 503 to a 404 the replication layer reads as "cleanly gone" (harper-pro#481).
|
|
2441
|
+
*/
|
|
2442
|
+
function blobHeaderIsAbortMarker(header: Buffer): boolean {
|
|
2443
|
+
if (header.length < HEADER_SIZE) return false;
|
|
2444
|
+
const type = header.readUInt16BE(0);
|
|
2445
|
+
return type === PENDING_TYPE || type === ERROR_TYPE;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* What a consumer capturing a blob root should do with one file. `pending` is a blob that was not
|
|
2450
|
+
* whole *yet* (retryable 503); `gone` is absent from this capture and represented as terminal 500.
|
|
2451
|
+
* Both still put a file at the id, because `getNextFileId` recovers the counter by scanning the
|
|
2452
|
+
* directory and an absent file lets a restored record's id be reissued.
|
|
2453
|
+
*/
|
|
2454
|
+
export type BlobCaptureDisposition = 'skip' | 'capture' | 'pending' | 'gone';
|
|
2455
|
+
|
|
2456
|
+
/** The stand-in bytes for a blob that could not be captured whole. */
|
|
2457
|
+
export function createCaptureMarker(disposition: 'pending' | 'gone', message: string): Buffer {
|
|
2458
|
+
const messageBuffer = Buffer.from(message);
|
|
2459
|
+
const header = Buffer.alloc(HEADER_SIZE);
|
|
2460
|
+
new DataView(header.buffer, header.byteOffset, HEADER_SIZE).setBigInt64(
|
|
2461
|
+
0,
|
|
2462
|
+
BigInt(messageBuffer.length) | (BigInt(disposition === 'gone' ? ERROR_TYPE : PENDING_TYPE) << 48n)
|
|
2463
|
+
);
|
|
2464
|
+
return Buffer.concat([header, messageBuffer]);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
1816
2467
|
export function blobHeaderIndicatesIncomplete(header: Buffer, fileSize: number): boolean {
|
|
1817
2468
|
if (fileSize < HEADER_SIZE || header.length < HEADER_SIZE) return true;
|
|
1818
2469
|
const type = header.readUInt16BE(0);
|
|
@@ -1823,10 +2474,78 @@ export function blobHeaderIndicatesIncomplete(header: Buffer, fileSize: number):
|
|
|
1823
2474
|
}
|
|
1824
2475
|
|
|
1825
2476
|
/**
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
2477
|
+
* What a blob file's header and length alone say about its completeness: `true` when the file is
|
|
2478
|
+
* missing, short, unfinished, an error/pending stub, or disagrees with the record descriptor; `false`
|
|
2479
|
+
* when an uncompressed body is whole; `null` for a deflate body, whose header records the uncompressed
|
|
2480
|
+
* length and so proves nothing about the body — only inflating it can. `undefined` when the question
|
|
2481
|
+
* does not apply or the file could not be inspected.
|
|
2482
|
+
*/
|
|
2483
|
+
function classifyBlobFileByHeader(
|
|
2484
|
+
blob: Blob,
|
|
2485
|
+
fileSize: number,
|
|
2486
|
+
header: Buffer,
|
|
2487
|
+
headerBytes: number
|
|
2488
|
+
): boolean | null | undefined {
|
|
2489
|
+
if (fileSize < HEADER_SIZE || headerBytes < HEADER_SIZE) return true;
|
|
2490
|
+
if (blobHeaderIndicatesIncomplete(header, fileSize)) return true;
|
|
2491
|
+
const descriptorSize = (blob as { size?: number }).size;
|
|
2492
|
+
if (descriptorSize !== undefined && header.readUIntBE(2, 6) !== descriptorSize) return true;
|
|
2493
|
+
return header.readUInt16BE(0) === DEFLATE_TYPE ? null : false;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/**
|
|
2497
|
+
* Whether a file-backed blob's backing file is missing or incomplete on disk — the unlocked,
|
|
2498
|
+
* non-blocking classification a copy-delivery repair candidate gets first (harper-pro#699). A deflate
|
|
2499
|
+
* body is inflated (streamed, off the event loop) to decide; the file it judged damaged is remembered
|
|
2500
|
+
* so the locked recheck in `repairBlobFile` can recognize it without inflating again. Returns
|
|
2501
|
+
* undefined for blobs the question does not apply to, or that could not be inspected.
|
|
2502
|
+
*/
|
|
2503
|
+
export async function blobFileMissingOrIncompleteAsync(blob: Blob): Promise<boolean | undefined> {
|
|
2504
|
+
if (!(blob instanceof FileBackedBlob)) return undefined;
|
|
2505
|
+
const storageInfo = storageInfoForBlob.get(blob);
|
|
2506
|
+
if (!storageInfo?.fileId) return undefined;
|
|
2507
|
+
storageInfo.probedDamage = undefined; // every probe starts over; no early exit may leave a stale verdict behind
|
|
2508
|
+
const filePath = getFilePath(storageInfo);
|
|
2509
|
+
let file: FileHandle;
|
|
2510
|
+
try {
|
|
2511
|
+
file = await openFile(filePath, 'r');
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
if ((error as { code?: string })?.code === 'ENOENT') return true;
|
|
2514
|
+
logger.debug?.('Unable to open blob file for in-place repair inspection', error);
|
|
2515
|
+
return undefined;
|
|
2516
|
+
}
|
|
2517
|
+
let fileSize: number;
|
|
2518
|
+
const header = Buffer.allocUnsafe(HEADER_SIZE);
|
|
2519
|
+
let verdict: boolean | null | undefined;
|
|
2520
|
+
try {
|
|
2521
|
+
fileSize = (await file.stat()).size;
|
|
2522
|
+
const { bytesRead } = await file.read(header, 0, HEADER_SIZE, 0);
|
|
2523
|
+
verdict = classifyBlobFileByHeader(blob, fileSize, header, bytesRead);
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
logger.debug?.('Unable to inspect blob file for in-place repair', error);
|
|
2526
|
+
return undefined;
|
|
2527
|
+
} finally {
|
|
2528
|
+
await file.close().catch(() => {});
|
|
2529
|
+
}
|
|
2530
|
+
if (verdict !== null) return verdict;
|
|
2531
|
+
try {
|
|
2532
|
+
verdict = !(await inflatesToExactly(filePath, header.readUIntBE(2, 6)));
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
logger.debug?.('Unable to inspect blob file for in-place repair', error);
|
|
2535
|
+
return undefined;
|
|
2536
|
+
}
|
|
2537
|
+
if (verdict) storageInfo.probedDamage = { fileSize, header };
|
|
2538
|
+
return verdict;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/**
|
|
2542
|
+
* The locked, synchronous recheck of `blobFileMissingOrIncompleteAsync`'s verdict, run by
|
|
2543
|
+
* `repairBlobFile` under the blob's write lock so a repair can only replace a file no writer is
|
|
2544
|
+
* still producing. It repeats the header classification, and for a deflate body — which it cannot
|
|
2545
|
+
* afford to inflate on the event loop — answers `true` only while the file is still the one the
|
|
2546
|
+
* probe found damaged: a body can only grow (writers append; a repair replaces the file) so an
|
|
2547
|
+
* unchanged length and header mean unchanged bytes, and anything else is `undefined` (not provably
|
|
2548
|
+
* damaged — decline, and let the next probe or the repair sweep classify it).
|
|
1830
2549
|
*/
|
|
1831
2550
|
export function blobFileMissingOrIncomplete(blob: Blob): boolean | undefined {
|
|
1832
2551
|
try {
|
|
@@ -1843,13 +2562,12 @@ export function blobFileMissingOrIncomplete(blob: Blob): boolean | undefined {
|
|
|
1843
2562
|
return undefined;
|
|
1844
2563
|
}
|
|
1845
2564
|
try {
|
|
1846
|
-
const
|
|
1847
|
-
if (size < HEADER_SIZE) return true;
|
|
2565
|
+
const fileSize = fstatSync(fd).size;
|
|
1848
2566
|
const header = Buffer.allocUnsafe(HEADER_SIZE);
|
|
1849
|
-
|
|
1850
|
-
if (
|
|
1851
|
-
const
|
|
1852
|
-
return
|
|
2567
|
+
const verdict = classifyBlobFileByHeader(blob, fileSize, header, readSync(fd, header, 0, HEADER_SIZE, 0));
|
|
2568
|
+
if (verdict !== null) return verdict;
|
|
2569
|
+
const probed = storageInfo.probedDamage;
|
|
2570
|
+
return probed?.fileSize === fileSize && probed.header.equals(header) ? true : undefined;
|
|
1853
2571
|
} finally {
|
|
1854
2572
|
closeSync(fd);
|
|
1855
2573
|
}
|
|
@@ -2377,6 +3095,10 @@ export function cleanupUnusedBlobs(blobs: Blob[] | undefined, retainedFileIds?:
|
|
|
2377
3095
|
const storageInfo = storageInfoForBlob.get(blob);
|
|
2378
3096
|
if (!storageInfo?.fileId || (blob as FileBackedBlob).saveInRecord) continue; // no file written, nothing to clean up
|
|
2379
3097
|
if (retainedFileIds?.has(storageInfo.fileId)) continue; // the committed record still references this blob
|
|
3098
|
+
// Tombstone the instance as soon as the deletion is DECIDED, not when the unlink is issued: the
|
|
3099
|
+
// unlink waits for an in-flight save to settle, and a re-store in that window would otherwise
|
|
3100
|
+
// mint a reference to a file that is already condemned (issue #2062).
|
|
3101
|
+
discardStorage(storageInfo);
|
|
2380
3102
|
const settle = storageInfo.saving ?? Promise.resolve();
|
|
2381
3103
|
settle.then(
|
|
2382
3104
|
() => deleteBlob(blob),
|
|
@@ -2431,7 +3153,7 @@ addExtension({
|
|
|
2431
3153
|
storageInfoForBlob.set(blob, {
|
|
2432
3154
|
storageIndex: 0,
|
|
2433
3155
|
fileId: null,
|
|
2434
|
-
contentBuffer: blobInfo[1] as
|
|
3156
|
+
contentBuffer: asBuffer(blobInfo[1] as Uint8Array),
|
|
2435
3157
|
});
|
|
2436
3158
|
blob.size = blobInfo[1]?.length;
|
|
2437
3159
|
}
|
|
@@ -2465,6 +3187,11 @@ addExtension({
|
|
|
2465
3187
|
throw new Error('Unable to save blob without file id');
|
|
2466
3188
|
}
|
|
2467
3189
|
storageInfo.recordId = encodeForStorageForRecordId;
|
|
3190
|
+
// Per-node hint only — a replication sender uses it to skip the header sniff for the
|
|
3191
|
+
// uncompressed majority, but always confirms against the local file header before sending
|
|
3192
|
+
// raw: a relayed record (or an in-place repair) can outlive the storage form recorded here.
|
|
3193
|
+
// Namespaced away from `codec`, which a media blob plausibly carries as its own property.
|
|
3194
|
+
if (storageInfo.compress || storageInfo.storedCodec) options.storedCodec = 'deflate';
|
|
2468
3195
|
// A record version being written now references this file, so any reclamation queued by an
|
|
2469
3196
|
// earlier supersession is void — the retain-on-update check in RecordEncoder only covers the
|
|
2470
3197
|
// write that supersedes, not a file already awaiting reclamation from a previous one.
|
|
@@ -2574,7 +3301,7 @@ function polyfillBlob() {
|
|
|
2574
3301
|
* @param database
|
|
2575
3302
|
*/
|
|
2576
3303
|
export async function cleanupOrphans(database: any, databaseName?: string) {
|
|
2577
|
-
const { HAS_BLOBS } = await import('./auditStore.ts');
|
|
3304
|
+
const { HAS_BLOBS, isAuditEntryWrite } = await import('./auditStore.ts');
|
|
2578
3305
|
let store: RootDatabase;
|
|
2579
3306
|
let auditStore: RootDatabase;
|
|
2580
3307
|
let orphansDeleted = 0;
|
|
@@ -2656,7 +3383,10 @@ export async function cleanupOrphans(database: any, databaseName?: string) {
|
|
|
2656
3383
|
const primaryStore = (auditStore as any).tableStores[(auditRecord as any).tableId];
|
|
2657
3384
|
if (!primaryStore) continue;
|
|
2658
3385
|
const entry = primaryStore?.getEntry((auditRecord as any).recordId);
|
|
2659
|
-
|
|
3386
|
+
// Only the write this audit record describes had its blobs scanned by the table loop above.
|
|
3387
|
+
// Identity is the log key, not the version: a version match can name a different write and
|
|
3388
|
+
// would skip an audit value whose blobs are still referenced.
|
|
3389
|
+
if (!entry?.value || !isAuditEntryWrite(entry, auditRecord as any)) {
|
|
2660
3390
|
checkObjectForReferences((auditRecord as any).getValue(primaryStore));
|
|
2661
3391
|
}
|
|
2662
3392
|
// slow this down a bit to reduce excessive load, this runs approximately at 10k per second
|
|
@@ -2713,10 +3443,12 @@ export async function cleanupOrphans(database: any, databaseName?: string) {
|
|
|
2713
3443
|
}
|
|
2714
3444
|
|
|
2715
3445
|
async function isBlobFileComplete(storageInfo: StorageInfo): Promise<boolean> {
|
|
2716
|
-
|
|
3446
|
+
return isBlobFileCompleteAtPath(getFilePath(storageInfo));
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
async function isBlobFileCompleteAtPath(filePath: string): Promise<boolean> {
|
|
2717
3450
|
let fileSize: number;
|
|
2718
3451
|
try {
|
|
2719
|
-
filePath = getFilePath(storageInfo);
|
|
2720
3452
|
fileSize = statSync(filePath).size;
|
|
2721
3453
|
} catch (e) {
|
|
2722
3454
|
if ((e as any).code === 'ENOENT') return false;
|
|
@@ -2738,32 +3470,55 @@ async function isBlobFileComplete(storageInfo: StorageInfo): Promise<boolean> {
|
|
|
2738
3470
|
// for a compressed blob it does not, so the body length can't be compared to it directly.
|
|
2739
3471
|
const size = Number(headerValue & 0xffffffffffffn);
|
|
2740
3472
|
if (header[1] === DEFLATE_TYPE) {
|
|
2741
|
-
//
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
|
|
2745
|
-
// the whole buffer) keeps memory bounded during the repair sweep, which may touch many large
|
|
2746
|
-
// blobs.
|
|
2747
|
-
return new Promise<boolean>((resolve) => {
|
|
2748
|
-
let inflatedLength = 0;
|
|
2749
|
-
const source = createReadStream(filePath, { start: HEADER_SIZE });
|
|
2750
|
-
const inflate = createInflate();
|
|
2751
|
-
const fail = () => {
|
|
2752
|
-
source.destroy();
|
|
2753
|
-
resolve(false);
|
|
2754
|
-
};
|
|
2755
|
-
source.on('error', fail);
|
|
2756
|
-
inflate.on('error', fail);
|
|
2757
|
-
inflate.on('data', (chunk: Buffer) => {
|
|
2758
|
-
inflatedLength += chunk.length;
|
|
2759
|
-
});
|
|
2760
|
-
inflate.on('end', () => resolve(inflatedLength === size));
|
|
2761
|
-
source.pipe(inflate);
|
|
2762
|
-
});
|
|
3473
|
+
// This function's contract is to resolve true/false; harper-pro's repair sweep awaits it without a
|
|
3474
|
+
// catch (replication/blobRepair.ts), so an I/O fault must not become a rejection here. The capture
|
|
3475
|
+
// classifier calls inflatesToExactly directly, where the distinction does matter.
|
|
3476
|
+
return inflatesToExactly(filePath, size).catch(() => false);
|
|
2763
3477
|
}
|
|
2764
3478
|
return true;
|
|
2765
3479
|
}
|
|
2766
3480
|
|
|
3481
|
+
/**
|
|
3482
|
+
* Whether the deflate body after the header inflates to exactly `size` bytes. A compressed blob's
|
|
3483
|
+
* header records the uncompressed length, so it cannot be compared to the on-disk body; a truncated
|
|
3484
|
+
* stream errors (Z_BUF_ERROR) or yields fewer bytes. Streamed rather than inflateSync so memory stays
|
|
3485
|
+
* bounded over a sweep that may touch many large blobs, and abandoned as soon as the output passes
|
|
3486
|
+
* `size` so a body that lies about its length costs no more than the length it claimed.
|
|
3487
|
+
*/
|
|
3488
|
+
export function inflatesToExactly(filePath: string, size: number): Promise<boolean> {
|
|
3489
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
3490
|
+
let inflatedLength = 0;
|
|
3491
|
+
const source = createReadStream(filePath, { start: HEADER_SIZE });
|
|
3492
|
+
const inflate = createInflate();
|
|
3493
|
+
// Both: pipe() does not tear down the destination when the source errors, so the inflate's
|
|
3494
|
+
// native zlib handle would sit allocated until GC — once per blob, on every backup walk.
|
|
3495
|
+
const stop = () => {
|
|
3496
|
+
source.destroy();
|
|
3497
|
+
inflate.destroy();
|
|
3498
|
+
};
|
|
3499
|
+
// A zlib error is the answer (body truncated or corrupt); an I/O error is a failure to answer and
|
|
3500
|
+
// must propagate, or a systemic fault would classify a corpus of complete blobs as incomplete.
|
|
3501
|
+
// The reject arm is unverified: it needs a read fault raised mid-inflate on a file that opened
|
|
3502
|
+
// cleanly, which no test here can produce.
|
|
3503
|
+
const fail = (error: NodeJS.ErrnoException) => {
|
|
3504
|
+
stop();
|
|
3505
|
+
if (error?.code?.startsWith('Z_')) resolve(false);
|
|
3506
|
+
else reject(error);
|
|
3507
|
+
};
|
|
3508
|
+
source.on('error', fail);
|
|
3509
|
+
inflate.on('error', fail);
|
|
3510
|
+
inflate.on('data', (chunk: Buffer) => {
|
|
3511
|
+
inflatedLength += chunk.length;
|
|
3512
|
+
if (inflatedLength > size) {
|
|
3513
|
+
stop();
|
|
3514
|
+
resolve(false);
|
|
3515
|
+
}
|
|
3516
|
+
});
|
|
3517
|
+
inflate.on('end', () => resolve(inflatedLength === size));
|
|
3518
|
+
source.pipe(inflate);
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
|
|
2767
3522
|
/**
|
|
2768
3523
|
* Resolves true if the given blob's backing file is present and complete: no error stub, not an
|
|
2769
3524
|
* in-flight placeholder, and the content matches the header (uncompressed blobs by body length,
|
|
@@ -2777,6 +3532,170 @@ export async function isBlobComplete(blob: Blob): Promise<boolean> {
|
|
|
2777
3532
|
return isBlobFileComplete(storageInfo);
|
|
2778
3533
|
}
|
|
2779
3534
|
|
|
3535
|
+
export type StoredBlobBody = {
|
|
3536
|
+
codec: 'deflate';
|
|
3537
|
+
size: number; // uncompressed content length, from the header
|
|
3538
|
+
/** The raw post-header bytes. Single-use; releases the file on completion, error, or teardown. */
|
|
3539
|
+
stream: () => AsyncGenerator<Buffer>;
|
|
3540
|
+
/** Release the descriptor and file hold without streaming. */
|
|
3541
|
+
close: () => void;
|
|
3542
|
+
};
|
|
3543
|
+
|
|
3544
|
+
/**
|
|
3545
|
+
* Open a blob's STORED body for transfer, without inflating it — the sender half of replication's
|
|
3546
|
+
* codec preservation. Returns undefined whenever the stored form is not a settled deflate file
|
|
3547
|
+
* (uncompressed, sliced, still being written, missing, or already condemned); the caller then falls
|
|
3548
|
+
* back to the ordinary content stream, which knows how to wait and how to report errors.
|
|
3549
|
+
*
|
|
3550
|
+
* Deliberately synchronous through the decision: the caller decides raw-vs-inflated inside the same
|
|
3551
|
+
* synchronous block that puts the owning record frame on the wire. The stream opens its own
|
|
3552
|
+
* descriptor later and re-reads the header first: a settled file's header never changes except by
|
|
3553
|
+
* a repair publishing a fresh (uncompressed) file over the path, so a header that differs from the
|
|
3554
|
+
* sniffed one means the announced form is gone — reported as transient (503), since a retry
|
|
3555
|
+
* re-decides against the new file. The stream verifies as it sends — a concurrent inflate must
|
|
3556
|
+
* land on exactly `size` bytes — so a torn body that survived the header checks becomes a loud,
|
|
3557
|
+
* permanently-classified error instead of replicated garbage.
|
|
3558
|
+
*/
|
|
3559
|
+
export function openStoredBlobBody(blob: Blob): StoredBlobBody | undefined {
|
|
3560
|
+
if (!(blob instanceof FileBackedBlob)) return undefined;
|
|
3561
|
+
const storageInfo = storageInfoForBlob.get(blob);
|
|
3562
|
+
if (!storageInfo?.fileId || !storageInfo.store) return undefined;
|
|
3563
|
+
if (storageInfo.start !== undefined || storageInfo.end !== undefined) return undefined;
|
|
3564
|
+
if (storageInfo.fileState?.discarded) return undefined;
|
|
3565
|
+
const releaseHold = holdBlobFile(blob);
|
|
3566
|
+
if (!releaseHold) return undefined; // already being reclaimed
|
|
3567
|
+
let fd: number | undefined;
|
|
3568
|
+
let filePath: string;
|
|
3569
|
+
const decline = () => {
|
|
3570
|
+
if (fd !== undefined) {
|
|
3571
|
+
try {
|
|
3572
|
+
closeSync(fd);
|
|
3573
|
+
} catch (error) {
|
|
3574
|
+
logger.debug?.('Error closing stored blob body descriptor', error);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
releaseHold();
|
|
3578
|
+
return undefined;
|
|
3579
|
+
};
|
|
3580
|
+
let size: number;
|
|
3581
|
+
const header = Buffer.allocUnsafe(HEADER_SIZE);
|
|
3582
|
+
try {
|
|
3583
|
+
const lockKey = storageInfo.fileId + ':blob';
|
|
3584
|
+
// A held write lock means the body may still be streaming to disk; the normal read path knows
|
|
3585
|
+
// how to wait for it, so decline rather than duplicate that machinery here.
|
|
3586
|
+
if (!storageInfo.store.tryLock(lockKey)) return decline();
|
|
3587
|
+
storageInfo.store.unlock(lockKey);
|
|
3588
|
+
filePath = getFilePath(storageInfo);
|
|
3589
|
+
fd = openSync(filePath, 'r');
|
|
3590
|
+
if (readSync(fd, header, 0, HEADER_SIZE, 0) < HEADER_SIZE) return decline();
|
|
3591
|
+
const headerValue = new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0);
|
|
3592
|
+
if (Number(headerValue >> 48n) !== DEFLATE_TYPE) return decline();
|
|
3593
|
+
size = Number(headerValue & 0xffffffffffffn);
|
|
3594
|
+
if (size === UNKNOWN_SIZE) return decline();
|
|
3595
|
+
const descriptorSize = (blob as { size?: number }).size;
|
|
3596
|
+
if (descriptorSize !== undefined && descriptorSize < UNKNOWN_SIZE && descriptorSize !== size) return decline();
|
|
3597
|
+
closeSync(fd);
|
|
3598
|
+
fd = undefined;
|
|
3599
|
+
} catch (error) {
|
|
3600
|
+
logger.debug?.('Unable to open stored blob body', storageInfo.fileId, error);
|
|
3601
|
+
return decline();
|
|
3602
|
+
}
|
|
3603
|
+
let holdReleased = false;
|
|
3604
|
+
const releaseHoldOnce = () => {
|
|
3605
|
+
if (holdReleased) return;
|
|
3606
|
+
holdReleased = true;
|
|
3607
|
+
releaseHold();
|
|
3608
|
+
};
|
|
3609
|
+
let streamStarted = false;
|
|
3610
|
+
const failVerification = (detail: string) =>
|
|
3611
|
+
new BlobReadError(`Stored blob body for ${filePath} ${detail}`, BLOB_CORRUPT_STATUS);
|
|
3612
|
+
return {
|
|
3613
|
+
codec: 'deflate',
|
|
3614
|
+
size,
|
|
3615
|
+
close: () => {
|
|
3616
|
+
streamStarted = true;
|
|
3617
|
+
releaseHoldOnce();
|
|
3618
|
+
},
|
|
3619
|
+
stream: async function* () {
|
|
3620
|
+
if (streamStarted) throw new Error(`Stored blob body for ${filePath} was already consumed or closed`);
|
|
3621
|
+
streamStarted = true;
|
|
3622
|
+
let streamFd: number | undefined;
|
|
3623
|
+
let source: ReadStream;
|
|
3624
|
+
try {
|
|
3625
|
+
streamFd = openSync(filePath, 'r');
|
|
3626
|
+
const currentHeader = Buffer.allocUnsafe(HEADER_SIZE);
|
|
3627
|
+
const headerBytes = readSync(streamFd, currentHeader, 0, HEADER_SIZE, 0);
|
|
3628
|
+
if (headerBytes < HEADER_SIZE || !currentHeader.equals(header)) {
|
|
3629
|
+
throw new BlobReadError(
|
|
3630
|
+
`Stored blob body for ${filePath} was replaced before it was streamed`,
|
|
3631
|
+
BLOB_UNAVAILABLE_STATUS
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
// the stream owns the descriptor from here (an fs stream's worker-thread close(2) races any other user)
|
|
3635
|
+
source = createReadStream(filePath, { fd: streamFd, start: HEADER_SIZE, autoClose: true });
|
|
3636
|
+
} catch (error) {
|
|
3637
|
+
if (streamFd !== undefined) closeSync(streamFd);
|
|
3638
|
+
releaseHoldOnce();
|
|
3639
|
+
throw error;
|
|
3640
|
+
}
|
|
3641
|
+
const verifier = createInflate();
|
|
3642
|
+
let inflated = 0;
|
|
3643
|
+
let verifyFailure: Error | undefined;
|
|
3644
|
+
// A mid-write zlib data error (a corrupt, not merely truncated, body) fires 'error'/'close'
|
|
3645
|
+
// but drops the pending `verifier.write` callback, so the per-chunk await below would hang
|
|
3646
|
+
// forever — suspending this generator inside the try and leaking the descriptor and the file
|
|
3647
|
+
// hold its finally releases. settleWrite() unblocks it from error/close too; whichever runs
|
|
3648
|
+
// first resolves the promise and clears the ref, so the write callback is a harmless no-op.
|
|
3649
|
+
let resolvePendingWrite: (() => void) | undefined;
|
|
3650
|
+
const settleWrite = () => {
|
|
3651
|
+
const resolve = resolvePendingWrite;
|
|
3652
|
+
resolvePendingWrite = undefined;
|
|
3653
|
+
resolve?.();
|
|
3654
|
+
};
|
|
3655
|
+
verifier.on('data', (chunk: Buffer) => {
|
|
3656
|
+
inflated += chunk.length;
|
|
3657
|
+
if (inflated > size && !verifyFailure) {
|
|
3658
|
+
verifyFailure = failVerification(`inflates past its declared size of ${size}`);
|
|
3659
|
+
verifier.destroy();
|
|
3660
|
+
}
|
|
3661
|
+
});
|
|
3662
|
+
verifier.on('error', (error) => {
|
|
3663
|
+
verifyFailure ??= failVerification(`is not a valid deflate stream: ${error.message}`);
|
|
3664
|
+
settleWrite();
|
|
3665
|
+
});
|
|
3666
|
+
verifier.on('close', settleWrite); // a destroy() with no 'error' (oversize) also drops the write callback
|
|
3667
|
+
try {
|
|
3668
|
+
for await (const chunk of source as AsyncIterable<Buffer>) {
|
|
3669
|
+
if (verifyFailure) throw verifyFailure;
|
|
3670
|
+
if (!verifier.destroyed) {
|
|
3671
|
+
// Yield only once the verifier consumed the chunk, so a detection surfaces before the
|
|
3672
|
+
// bytes it condemns are handed to the caller's wire frame (and never after the caller
|
|
3673
|
+
// has already seen a clean end).
|
|
3674
|
+
await new Promise<void>((resolveWrite) => {
|
|
3675
|
+
resolvePendingWrite = resolveWrite;
|
|
3676
|
+
verifier.write(chunk, settleWrite);
|
|
3677
|
+
});
|
|
3678
|
+
if (verifyFailure) throw verifyFailure;
|
|
3679
|
+
}
|
|
3680
|
+
yield chunk;
|
|
3681
|
+
}
|
|
3682
|
+
if (!verifier.destroyed) {
|
|
3683
|
+
await new Promise<void>((resolveEnd) => {
|
|
3684
|
+
verifier.on('close', resolveEnd);
|
|
3685
|
+
verifier.end();
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
if (verifyFailure) throw verifyFailure;
|
|
3689
|
+
if (inflated !== size) throw failVerification(`inflated to ${inflated} bytes of a declared ${size}`);
|
|
3690
|
+
} finally {
|
|
3691
|
+
source.destroy();
|
|
3692
|
+
verifier.destroy();
|
|
3693
|
+
releaseHoldOnce();
|
|
3694
|
+
}
|
|
3695
|
+
},
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
|
|
2780
3699
|
/**
|
|
2781
3700
|
* Async generator that yields records whose referenced blob files are missing, truncated, or in an
|
|
2782
3701
|
* error state. Used by the blob repair sweep to find candidates for peer-fetched recovery.
|