@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cliOperations.ts +12 -0
- package/bin/restart.ts +66 -6
- package/components/Application.ts +1134 -112
- package/components/DESIGN.md +421 -0
- package/components/OptionsWatcher.ts +368 -102
- package/components/Scope.ts +20 -1
- package/components/componentLoader.ts +43 -4
- package/components/deploymentOperations.ts +4 -1
- package/components/deploymentRecorder.ts +9 -2
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/components/operations.js +284 -52
- package/components/operationsValidation.js +49 -2
- package/components/packageComponent.ts +25 -1
- package/components/requestRestart.ts +11 -0
- package/config/DESIGN.md +306 -0
- package/config/RootConfigWatcher.ts +191 -37
- package/config/configReadRetry.ts +62 -0
- package/config/configUtils.ts +78 -26
- package/config/parseConfigFile.ts +34 -0
- package/config/readConfigFileSync.ts +44 -0
- package/config/watcherArming.ts +59 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/bin/cliOperations.js +13 -0
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/restart.js +42 -6
- package/dist/bin/restart.js.map +1 -1
- package/dist/components/Application.d.ts +104 -9
- package/dist/components/Application.js +954 -102
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +4 -1
- package/dist/components/OptionsWatcher.js +378 -104
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.js +15 -1
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +35 -3
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentOperations.js +4 -1
- package/dist/components/deploymentOperations.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +4 -2
- package/dist/components/deploymentRecorder.js +1 -0
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/components/operations.d.ts +28 -0
- package/dist/components/operations.js +263 -54
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +48 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/packageComponent.js +24 -0
- package/dist/components/packageComponent.js.map +1 -1
- package/dist/components/requestRestart.d.ts +1 -0
- package/dist/components/requestRestart.js +7 -0
- package/dist/components/requestRestart.js.map +1 -1
- package/dist/config/RootConfigWatcher.d.ts +2 -0
- package/dist/config/RootConfigWatcher.js +189 -35
- package/dist/config/RootConfigWatcher.js.map +1 -1
- package/dist/config/configReadRetry.d.ts +8 -0
- package/dist/config/configReadRetry.js +62 -0
- package/dist/config/configReadRetry.js.map +1 -0
- package/dist/config/configUtils.d.ts +10 -9
- package/dist/config/configUtils.js +63 -27
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/parseConfigFile.d.ts +4 -0
- package/dist/config/parseConfigFile.js +35 -0
- package/dist/config/parseConfigFile.js.map +1 -0
- package/dist/config/readConfigFileSync.d.ts +1 -0
- package/dist/config/readConfigFileSync.js +47 -0
- package/dist/config/readConfigFileSync.js.map +1 -0
- package/dist/config/watcherArming.d.ts +15 -0
- package/dist/config/watcherArming.js +59 -0
- package/dist/config/watcherArming.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +3 -0
- package/dist/resources/DatabaseTransaction.d.ts +25 -0
- package/dist/resources/DatabaseTransaction.js +224 -18
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +2 -1
- package/dist/resources/LMDBTransaction.js +22 -3
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.js +22 -6
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/RecordEncoder.d.ts +1 -1
- package/dist/resources/RecordEncoder.js +46 -10
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +97 -13
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -0
- package/dist/resources/RocksIndexStore.js +2 -1
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
- package/dist/resources/RocksTransactionLogStore.js +104 -33
- package/dist/resources/RocksTransactionLogStore.js.map +1 -1
- package/dist/resources/Table.d.ts +58 -7
- package/dist/resources/Table.js +1143 -348
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +49 -16
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +170 -0
- package/dist/resources/auditStore.js +457 -11
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/dataLoader.js +3 -4
- package/dist/resources/dataLoader.js.map +1 -1
- package/dist/resources/databases.d.ts +16 -13
- package/dist/resources/databases.js +625 -177
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRegistry.d.ts +5 -0
- package/dist/resources/derivedIndexRegistry.js +68 -0
- package/dist/resources/derivedIndexRegistry.js.map +1 -0
- package/dist/resources/derivedIndexRuntime.d.ts +215 -0
- package/dist/resources/derivedIndexRuntime.js +2027 -0
- package/dist/resources/derivedIndexRuntime.js.map +1 -0
- package/dist/resources/graphql.js +3 -2
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
- package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
- package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
- package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
- package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/nodeIdMapping.d.ts +5 -0
- package/dist/resources/nodeIdMapping.js +49 -0
- package/dist/resources/nodeIdMapping.js.map +1 -1
- package/dist/resources/recordLock.d.ts +47 -4
- package/dist/resources/recordLock.js +138 -7
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/recordLockCoordinator.d.ts +557 -0
- package/dist/resources/recordLockCoordinator.js +2597 -0
- package/dist/resources/recordLockCoordinator.js.map +1 -0
- package/dist/resources/replayLogs.js +5 -0
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/replicatedApplyFailure.d.ts +16 -0
- package/dist/resources/replicatedApplyFailure.js +63 -0
- package/dist/resources/replicatedApplyFailure.js.map +1 -0
- package/dist/resources/scheduler/scheduler.js +3 -3
- package/dist/resources/scheduler/scheduler.js.map +1 -1
- package/dist/resources/search.d.ts +10 -4
- package/dist/resources/search.js +160 -40
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/tracked.d.ts +5 -1
- package/dist/resources/tracked.js +74 -23
- package/dist/resources/tracked.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +9 -4
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +39 -5
- package/dist/server/REST.js.map +1 -1
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +34 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/Headers.d.ts +2 -0
- package/dist/server/serverHelpers/Headers.js +6 -0
- package/dist/server/serverHelpers/Headers.js.map +1 -1
- package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
- package/dist/server/serverHelpers/Request.d.ts +5 -10
- package/dist/server/serverHelpers/Request.js +38 -136
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
- package/dist/server/serverHelpers/contentTypes.js +189 -15
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +110 -20
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/server/storageReclamation.js +1 -1
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/isolatedApplications.d.ts +47 -0
- package/dist/server/threads/isolatedApplications.js +171 -0
- package/dist/server/threads/isolatedApplications.js.map +1 -0
- package/dist/server/threads/logRotationTransport.d.ts +1 -0
- package/dist/server/threads/logRotationTransport.js +33 -0
- package/dist/server/threads/logRotationTransport.js.map +1 -0
- package/dist/server/threads/manageThreads.d.ts +64 -6
- package/dist/server/threads/manageThreads.js +261 -12
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.d.ts +1 -0
- package/dist/server/threads/socketRouter.js +196 -13
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +30 -7
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +24 -0
- package/dist/utility/errors/hdbError.js +58 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +2 -0
- package/dist/utility/hdbTerms.js +2 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.js +218 -38
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
- package/dist/utility/logging/logGenerationCoordinator.js +184 -0
- package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
- package/dist/utility/logging/logRotation.d.ts +46 -0
- package/dist/utility/logging/logRotation.js +365 -0
- package/dist/utility/logging/logRotation.js.map +1 -0
- package/dist/utility/logging/logRotator.d.ts +1 -1
- package/dist/utility/logging/logRotator.js +172 -92
- package/dist/utility/logging/logRotator.js.map +1 -1
- package/dist/utility/npmUtilities.js +6 -4
- package/dist/utility/npmUtilities.js.map +1 -1
- package/dist/utility/watcherFallback.d.ts +0 -45
- package/dist/utility/watcherFallback.js +1 -125
- package/dist/utility/watcherFallback.js.map +1 -1
- package/dist/validation/configValidator.js +6 -3
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +9 -0
- package/json/systemSchema.json +3 -0
- package/npm-shrinkwrap.json +177 -87
- package/package.json +13 -5
- package/resources/DESIGN.md +689 -19
- package/resources/DatabaseTransaction.ts +230 -17
- package/resources/LMDBTransaction.ts +21 -3
- package/resources/PrimaryRocksDatabase.ts +20 -7
- package/resources/RecordEncoder.ts +52 -10
- package/resources/Resource.ts +97 -13
- package/resources/ResourceInterface.ts +8 -0
- package/resources/RocksIndexStore.ts +2 -1
- package/resources/RocksTransactionLogStore.ts +111 -31
- package/resources/Table.ts +1273 -396
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +50 -17
- package/resources/auditStore.ts +460 -11
- package/resources/crdt.ts +22 -0
- package/resources/dataLoader.ts +3 -4
- package/resources/databases.ts +611 -147
- package/resources/derivedIndexRegistry.ts +56 -0
- package/resources/derivedIndexRuntime.ts +2292 -0
- package/resources/graphql.ts +3 -2
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/hnswDerivedIndex.ts +531 -0
- package/resources/indexes/hnswPlaneBinding.ts +174 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/nodeIdMapping.ts +50 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLock.ts +173 -7
- package/resources/recordLockCoordinator.ts +3082 -0
- package/resources/replayLogs.ts +5 -0
- package/resources/replicatedApplyFailure.ts +77 -0
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/scheduler/scheduler.ts +4 -4
- package/resources/search.ts +169 -49
- package/resources/tracked.ts +73 -22
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +9 -4
- package/server/DESIGN.md +275 -0
- package/server/REST.ts +42 -6
- package/server/http.ts +34 -2
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/Headers.ts +5 -1
- package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
- package/server/serverHelpers/Request.ts +33 -131
- package/server/serverHelpers/contentTypes.ts +188 -15
- package/server/serverHelpers/serverUtilities.ts +157 -27
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/server/storageReclamation.ts +2 -2
- package/server/threads/isolatedApplications.ts +157 -0
- package/server/threads/logRotationTransport.ts +40 -0
- package/server/threads/manageThreads.js +254 -12
- package/server/threads/socketRouter.ts +217 -11
- package/server/threads/threadServer.js +30 -7
- package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/errors/hdbError.ts +54 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/logging/harper_logger.ts +210 -30
- package/utility/logging/logGenerationCoordinator.ts +196 -0
- package/utility/logging/logRotation.ts +367 -0
- package/utility/logging/logRotator.ts +196 -91
- package/utility/npmUtilities.ts +6 -4
- package/utility/watcherFallback.ts +0 -122
- package/validation/configValidator.ts +6 -3
package/resources/Table.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { Script } from 'node:vm';
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { performance } from 'node:perf_hooks';
|
|
18
18
|
import { getIndexedValues, getNextMonotonicTime } from '../utility/lmdb/commonUtility.ts';
|
|
19
|
-
import { getThisNodeId, exportIdMapping } from './nodeIdMapping.ts';
|
|
19
|
+
import { getThisNodeId, exportIdMapping, getNodeNameForId } from './nodeIdMapping.ts';
|
|
20
20
|
import lodash from 'lodash';
|
|
21
21
|
import { ExtendedIterable, SKIP } from '@harperfast/extended-iterable';
|
|
22
22
|
import type {
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
isReleasedTransaction,
|
|
42
42
|
TRANSACTION_STATE,
|
|
43
43
|
writeKeyId,
|
|
44
|
+
closeWriteInstance,
|
|
45
|
+
type WriteGeneration,
|
|
44
46
|
} from './DatabaseTransaction.ts';
|
|
45
47
|
import {
|
|
46
48
|
acquireRecordKey,
|
|
@@ -49,21 +51,27 @@ import {
|
|
|
49
51
|
resolveLockOptions,
|
|
50
52
|
type RecordLockHandle,
|
|
51
53
|
type RecordLockOptions,
|
|
54
|
+
type ResolvedRecordLockOptions,
|
|
52
55
|
} from './recordLock.ts';
|
|
56
|
+
import { getThisNodeName } from '../server/nodeName.ts';
|
|
53
57
|
import * as envMngr from '../utility/environment/environmentManager.ts';
|
|
54
58
|
import { addSubscription } from './transactionBroadcast.ts';
|
|
55
59
|
import {
|
|
60
|
+
DerivedIndexLagError,
|
|
56
61
|
handleHDBError,
|
|
57
62
|
ClientError,
|
|
58
63
|
ServerError,
|
|
59
64
|
AccessViolation,
|
|
60
65
|
ValidationError,
|
|
61
66
|
UpdateAttributesLockTimeoutError,
|
|
67
|
+
LockUnavailableError,
|
|
68
|
+
appendErrorContext,
|
|
62
69
|
type ValidationIssue,
|
|
63
70
|
} from '../utility/errors/hdbError.ts';
|
|
64
71
|
import * as signalling from '../utility/signalling.ts';
|
|
65
72
|
import { SchemaEventMsg, UserEventMsg } from '../server/threads/itc.js';
|
|
66
73
|
import { databases, table } from './databases.ts';
|
|
74
|
+
import { notifyReplicatedApplyFailure } from './replicatedApplyFailure.ts';
|
|
67
75
|
import {
|
|
68
76
|
searchByIndex,
|
|
69
77
|
findAttribute,
|
|
@@ -76,11 +84,45 @@ import {
|
|
|
76
84
|
} from './search.ts';
|
|
77
85
|
import { logger } from '../utility/logging/logger.ts';
|
|
78
86
|
import { isStaticResourceInstance } from './staticResourceDispatch.ts';
|
|
79
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
Addition,
|
|
89
|
+
assignTrackedAccessors,
|
|
90
|
+
updateAndFreeze,
|
|
91
|
+
hasChanges,
|
|
92
|
+
GenericTrackedObject,
|
|
93
|
+
ASSERT_TRACKED_WRITABLE,
|
|
94
|
+
GET_TRACKED_WRITE_GENERATION,
|
|
95
|
+
} from './tracked.ts';
|
|
80
96
|
import { transaction, contextStorage } from './transaction.ts';
|
|
81
97
|
import { MAXIMUM_KEY, writeKey, compareKeys } from 'ordered-binary';
|
|
82
|
-
import {
|
|
83
|
-
|
|
98
|
+
import {
|
|
99
|
+
getWorkerIndex,
|
|
100
|
+
applicationWorkerIndex,
|
|
101
|
+
ownsStoreMaintenance,
|
|
102
|
+
ownsStoreExpiration,
|
|
103
|
+
runsApplicationCodeSingletons,
|
|
104
|
+
isDedicatedWorker,
|
|
105
|
+
} from '../server/threads/manageThreads.js';
|
|
106
|
+
import {
|
|
107
|
+
HAS_BLOBS,
|
|
108
|
+
LOCAL_ONLY,
|
|
109
|
+
auditRetention,
|
|
110
|
+
removeAuditEntry,
|
|
111
|
+
getAuditFloor,
|
|
112
|
+
raiseAuditFloor,
|
|
113
|
+
boundedAuditPruneEnd,
|
|
114
|
+
isLockControlType,
|
|
115
|
+
} from './auditStore.ts';
|
|
116
|
+
import { derivedIndexWriteRejection, hasDerivedIndexRegistration } from './derivedIndexRegistry.ts';
|
|
117
|
+
import {
|
|
118
|
+
decodeLockControlPayload,
|
|
119
|
+
encodeLockControlPayload,
|
|
120
|
+
getClusterLockTransport,
|
|
121
|
+
isClusterLockRequired,
|
|
122
|
+
setLockCoordinatorResolver,
|
|
123
|
+
LockCoordinator,
|
|
124
|
+
type LockControlEntry,
|
|
125
|
+
} from './recordLockCoordinator.ts';
|
|
84
126
|
import { buildEmbedBefore, createDefaultEmbedder, type EmbedAttribute, type Embedder } from './models/embedHook.ts';
|
|
85
127
|
import { autoCast, autoCastBooleanStrict } from '../utility/common_utils.ts';
|
|
86
128
|
import {
|
|
@@ -94,7 +136,7 @@ import {
|
|
|
94
136
|
storedFieldsOnly,
|
|
95
137
|
} from './RecordEncoder.ts';
|
|
96
138
|
import { recordAction, recordActionBinary } from './analytics/write.ts';
|
|
97
|
-
import { rebuildUpdateBefore } from './crdt.ts';
|
|
139
|
+
import { commutativeOpsOf, rebuildUpdateBefore } from './crdt.ts';
|
|
98
140
|
import { appendHeader } from '../server/serverHelpers/Headers.ts';
|
|
99
141
|
import fs from 'node:fs';
|
|
100
142
|
import { Blob, deleteBlobsInObject, findBlobsInObject, startPreCommitBlobsForRecord } from './blob.ts';
|
|
@@ -146,6 +188,9 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
146
188
|
|
|
147
189
|
const NULL_WITH_TIMESTAMP = new Uint8Array(9);
|
|
148
190
|
NULL_WITH_TIMESTAMP[8] = 0xc0; // null
|
|
191
|
+
const sourceWriteTypes = new Set(['put', 'patch', 'delete', 'publish', 'message', 'invalidate', 'relocate']);
|
|
192
|
+
const isSourceWriteType = (type: string) => sourceWriteTypes.has(type);
|
|
193
|
+
const SOURCE_APPLY_POSITION = Symbol('sourceApplyPosition');
|
|
149
194
|
const UNCACHEABLE_TIMESTAMP = Infinity; // we use this when dynamic content is accessed that we can't safely cache, and this prevents earlier timestamps from change the "last" modification
|
|
150
195
|
const MAX_DATE_TIMESTAMP = 8.64e15;
|
|
151
196
|
const RECORD_PRUNING_INTERVAL = 60000; // one minute
|
|
@@ -173,6 +218,20 @@ const MAX_COUNT_PAGE = 10_000;
|
|
|
173
218
|
// How often the exact-count drain yields to the macrotask queue (must be a power of two for the bit-mask
|
|
174
219
|
// check). Keeps a large scan from monopolizing the event loop without adding a yield per row.
|
|
175
220
|
const COUNT_YIELD_INTERVAL = 2_048;
|
|
221
|
+
// Smallest forward sample `getRecordCount` will extrapolate a record rate from; below it the scan runs
|
|
222
|
+
// to completion and reports an exact count.
|
|
223
|
+
const MIN_ESTIMATOR_SAMPLE = 1_000;
|
|
224
|
+
// Budget intervals the forward scan may spend before it must estimate rather than keep scanning.
|
|
225
|
+
const MAX_ESTIMATE_CHECKPOINTS = 20;
|
|
226
|
+
// A store estimate's `count`, or 0 when the store answered with a shape that cannot be trusted --
|
|
227
|
+
// DESIGN.md's invariant for this API family is that such an answer degrades rather than poisons.
|
|
228
|
+
function usableCount(estimate: any): number {
|
|
229
|
+
const { count, confidence } = estimate ?? {};
|
|
230
|
+
// `confidence` needs its own finiteness check, not just the range: `null >= 0 && null <= 1` is true
|
|
231
|
+
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)
|
|
232
|
+
return 0;
|
|
233
|
+
return count;
|
|
234
|
+
}
|
|
176
235
|
envMngr.initSync();
|
|
177
236
|
const LMDB_PREFETCH_WRITES = envMngr.get(CONFIG_PARAMS.STORAGE_PREFETCHWRITES);
|
|
178
237
|
const LOCK_TIMEOUT = 10000;
|
|
@@ -491,6 +550,24 @@ function contextArgument(context: unknown): any {
|
|
|
491
550
|
return resolved instanceof DatabaseTransaction ? { transaction: resolved } : resolved;
|
|
492
551
|
}
|
|
493
552
|
|
|
553
|
+
/** The cluster round never ran for a node-scoped handle, so no peer ever deferred to it. */
|
|
554
|
+
function scopeViolation(
|
|
555
|
+
handle: RecordLockHandle,
|
|
556
|
+
resolved: ResolvedRecordLockOptions,
|
|
557
|
+
databaseName: string
|
|
558
|
+
): ClientError | undefined {
|
|
559
|
+
if (resolved.scope !== 'cluster' || handle.clusterTsR !== undefined) return undefined;
|
|
560
|
+
// The same predicate lock() fails closed on, not the transport alone: a coalesced caller re-checks
|
|
561
|
+
// this after its wait, and a transport unregistered during that wait leaves the database still
|
|
562
|
+
// clustered while the lookup answers undefined. Only the implicit Phase 0 case falls through.
|
|
563
|
+
if (!resolved.scopeRequested && !isClusterLockRequired(databaseName) && !getClusterLockTransport(databaseName))
|
|
564
|
+
return undefined;
|
|
565
|
+
return new ClientError(
|
|
566
|
+
'This transaction already holds a node-scoped lock on this record, so a cluster-scoped lock cannot be taken on top of it',
|
|
567
|
+
409
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
494
571
|
/** Distinguishes bare lock options from a record target (id, URL, {id:...}). */
|
|
495
572
|
function isPlainOptions(value: unknown): boolean {
|
|
496
573
|
return (
|
|
@@ -502,6 +579,18 @@ function isPlainOptions(value: unknown): boolean {
|
|
|
502
579
|
);
|
|
503
580
|
}
|
|
504
581
|
|
|
582
|
+
// Lets a transport push a received control entry straight to the right coordinator without
|
|
583
|
+
// importing Table (which would be a cycle through databases.ts).
|
|
584
|
+
setLockCoordinatorResolver(
|
|
585
|
+
(database: string, tableName: string) => (databases as any)[database]?.[tableName]?.lockCoordinator,
|
|
586
|
+
(database: string, tableName: string) => (databases as any)[database]?.[tableName]?.admittingCoordinator,
|
|
587
|
+
(database: string, tableName: string) => {
|
|
588
|
+
const Table = (databases as any)[database]?.[tableName];
|
|
589
|
+
if (typeof Table?.writeLockControlEntry !== 'function') return undefined;
|
|
590
|
+
return (entry: LockControlEntry) => Table.writeLockControlEntry(entry);
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
|
|
505
594
|
export function makeTable(options) {
|
|
506
595
|
const {
|
|
507
596
|
primaryKey,
|
|
@@ -523,6 +612,11 @@ export function makeTable(options) {
|
|
|
523
612
|
isBranch,
|
|
524
613
|
} = options;
|
|
525
614
|
let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options;
|
|
615
|
+
// Set when the TTL exists only on this thread: either application code configured it at runtime, or
|
|
616
|
+
// an isolated application's schema was declared here. Hydrating persisted metadata does not set it:
|
|
617
|
+
// dedicated workers open unrelated shared tables too, whose scan remains owned by the pool.
|
|
618
|
+
let ttlConfiguredByApplication = false;
|
|
619
|
+
let ttlFromLoad = false; // true only around the creation-time call below
|
|
526
620
|
evictionMs ??= 0;
|
|
527
621
|
// Eviction without explicit expiration means expiration:0. Apply at construction so
|
|
528
622
|
// describe_all sees it on every worker, not just ones that ran setTTLExpiration.
|
|
@@ -532,6 +626,9 @@ export function makeTable(options) {
|
|
|
532
626
|
if (!attributes) attributes = [];
|
|
533
627
|
if (!properties) properties = projectAttributesToProperties(attributes);
|
|
534
628
|
const updateRecord = recordUpdater(primaryStore, tableId, auditStore);
|
|
629
|
+
// Created on first cluster-scoped lock() or first arriving control entry, and only while a
|
|
630
|
+
// transport is registered for this database.
|
|
631
|
+
let lockCoordinator: LockCoordinator | undefined;
|
|
535
632
|
let warnedNullSourcePut = false; // latched: one warn per table per worker (see _writeUpdate)
|
|
536
633
|
let warnedFutureSourceVersion = false; // likewise (see getFromSource)
|
|
537
634
|
let sourceLoad: any; // if a source has a load function (replicator), record it here
|
|
@@ -563,7 +660,7 @@ export function makeTable(options) {
|
|
|
563
660
|
let nonPrefetchSequence = 2;
|
|
564
661
|
let cleanupInterval = 86400000;
|
|
565
662
|
let cleanupPriority = 0;
|
|
566
|
-
let lastCleanupInterval: number;
|
|
663
|
+
let lastCleanupInterval: number | undefined;
|
|
567
664
|
let cleanupTimer: NodeJS.Timeout;
|
|
568
665
|
let recordExpirationInterval: NodeJS.Timeout;
|
|
569
666
|
// a reclamation pass awaits a scheduled cleanup, which only settles from its timer
|
|
@@ -746,6 +843,29 @@ export function makeTable(options) {
|
|
|
746
843
|
}
|
|
747
844
|
return { txnLogKey: version, nodeId };
|
|
748
845
|
}
|
|
846
|
+
// Canonical-source applies (sourceApply), replay and replication notifications are never shed;
|
|
847
|
+
// dropping one would advance the source cursor past a write that never landed.
|
|
848
|
+
function assertDerivedIndexAdmission(options: any, transaction: any) {
|
|
849
|
+
if (options?.isNotification || transaction?.sourceApply || transaction?.isReplay) return;
|
|
850
|
+
const reason = derivedIndexWriteRejection(auditStore, tableId);
|
|
851
|
+
if (reason) throw new DerivedIndexLagError(reason);
|
|
852
|
+
}
|
|
853
|
+
function stageDerivedIndexEviction(transaction: RocksTransaction, id: Id, version: number) {
|
|
854
|
+
if (!hasDerivedIndexRegistration(auditStore, tableId)) return;
|
|
855
|
+
const nodeId = getThisNodeId(auditStore) ?? 0;
|
|
856
|
+
auditStore.put(
|
|
857
|
+
null,
|
|
858
|
+
{
|
|
859
|
+
type: 'evict',
|
|
860
|
+
tableId,
|
|
861
|
+
recordId: id,
|
|
862
|
+
version,
|
|
863
|
+
nodeId,
|
|
864
|
+
extendedType: LOCAL_ONLY,
|
|
865
|
+
},
|
|
866
|
+
{ transaction, nodeId }
|
|
867
|
+
);
|
|
868
|
+
}
|
|
749
869
|
class TableResource<Record extends object = any> extends Resource<Record> {
|
|
750
870
|
#record: any; // the stored/frozen record from the database and stored in the cache (should not be modified directly)
|
|
751
871
|
#changes: any; // the changes to the record that have been made (should not be modified directly)
|
|
@@ -754,7 +874,17 @@ export function makeTable(options) {
|
|
|
754
874
|
#savingOperation?: any; // operation for the record is currently being saved
|
|
755
875
|
#lockHandle?: RecordLockHandle; // the record lock acquired by lock() — scoped or hold
|
|
756
876
|
#lockWritable?: boolean; // set by #reloadLocked to let save() stage lock-writable updates
|
|
877
|
+
#writeGeneration?: WriteGeneration;
|
|
757
878
|
declare getProperty: (name: string) => any;
|
|
879
|
+
[ASSERT_TRACKED_WRITABLE](generation = this.#writeGeneration): void {
|
|
880
|
+
if (!generation) return;
|
|
881
|
+
if (generation.internalWrites > 0) return;
|
|
882
|
+
if (generation !== this.#writeGeneration || generation.closed)
|
|
883
|
+
throw new ClientError('Can not modify an update instance after it has been saved; call update() again', 409);
|
|
884
|
+
}
|
|
885
|
+
[GET_TRACKED_WRITE_GENERATION](): WriteGeneration {
|
|
886
|
+
return (this.#writeGeneration ??= { closed: false, internalWrites: 0 });
|
|
887
|
+
}
|
|
758
888
|
|
|
759
889
|
/**
|
|
760
890
|
* Shared guard: if this instance is lock-writable but the handle is gone (expired or
|
|
@@ -762,13 +892,15 @@ export function makeTable(options) {
|
|
|
762
892
|
* in addition to the save() path. Every lock-writable instance carries its own handle in
|
|
763
893
|
* #lockHandle (scoped and hold alike), so we never need to search the registry here.
|
|
764
894
|
*/
|
|
765
|
-
#assertLiveHandle(id: Id): void {
|
|
895
|
+
#assertLiveHandle(id: Id, allowClosed = false): void {
|
|
896
|
+
if (!allowClosed && this.#writeGeneration?.closed && writeKeyId(id) === writeKeyId(this.getId()))
|
|
897
|
+
this[ASSERT_TRACKED_WRITABLE]();
|
|
766
898
|
if (!this.#lockWritable) return;
|
|
767
899
|
const handle = this.#lockHandle!;
|
|
768
900
|
// Off-key writes through the same resource instance are ordinary; only guard the
|
|
769
901
|
// exact key the lock was acquired for.
|
|
770
902
|
if (handle.keyId !== writeKeyId(id)) return;
|
|
771
|
-
if (handle.
|
|
903
|
+
if (handle.isExpired()) {
|
|
772
904
|
throw lockNotHeldError(handle);
|
|
773
905
|
}
|
|
774
906
|
}
|
|
@@ -780,6 +912,13 @@ export function makeTable(options) {
|
|
|
780
912
|
static tableName = tableName;
|
|
781
913
|
static tableId = tableId;
|
|
782
914
|
static indices = indices;
|
|
915
|
+
static derivedIndexRuntime:
|
|
916
|
+
| {
|
|
917
|
+
close(dropping?: boolean): Promise<void>;
|
|
918
|
+
restoreAfterFailedDrop?(): typeof TableResource.derivedIndexRuntime;
|
|
919
|
+
completeDrop?(dropped?: boolean): void;
|
|
920
|
+
}
|
|
921
|
+
| undefined;
|
|
783
922
|
static audit = audit;
|
|
784
923
|
static databasePath = databasePath;
|
|
785
924
|
static databaseName = databaseName;
|
|
@@ -866,8 +1005,65 @@ export function makeTable(options) {
|
|
|
866
1005
|
(async () => {
|
|
867
1006
|
let userRoleUpdate = false;
|
|
868
1007
|
let lastSequenceId;
|
|
1008
|
+
let pendingApplyFailures: Promise<void> | undefined;
|
|
1009
|
+
const reportDroppedWrite = (event, context, error) => {
|
|
1010
|
+
const position =
|
|
1011
|
+
event === context ? context[SOURCE_APPLY_POSITION] : (event.timestamp ?? context[SOURCE_APPLY_POSITION]);
|
|
1012
|
+
const notification = notifyReplicatedApplyFailure(
|
|
1013
|
+
databaseName,
|
|
1014
|
+
{
|
|
1015
|
+
nodeId: event.nodeId ?? context.nodeId,
|
|
1016
|
+
table: event.table ?? context.table,
|
|
1017
|
+
localTime: event.localTime ?? context.localTime,
|
|
1018
|
+
},
|
|
1019
|
+
position,
|
|
1020
|
+
error,
|
|
1021
|
+
tableName
|
|
1022
|
+
);
|
|
1023
|
+
pendingApplyFailures = pendingApplyFailures
|
|
1024
|
+
? Promise.all([pendingApplyFailures, notification]).then(noop)
|
|
1025
|
+
: notification;
|
|
1026
|
+
return notification;
|
|
1027
|
+
};
|
|
1028
|
+
/** Cluster lock coordination entries (harper#483 Phase 1) describe no record. */
|
|
1029
|
+
const applyLockControlEvent = (event, context) => {
|
|
1030
|
+
const entry = decodeLockControlPayload(event.type, event.value);
|
|
1031
|
+
if (!entry) {
|
|
1032
|
+
logger.warn?.('discarding a malformed record lock control entry from', event.nodeId, event.type);
|
|
1033
|
+
return reportDroppedWrite(event, context, new Error('Malformed record lock control entry'));
|
|
1034
|
+
}
|
|
1035
|
+
const target = event.table ? databases[databaseName]?.[event.table] : TableResource;
|
|
1036
|
+
try {
|
|
1037
|
+
// The audit header's nodeId is the origin, translated on receive and preserved across
|
|
1038
|
+
// relays. The payload's own names are peer-supplied and prove nothing. Rebuild the id
|
|
1039
|
+
// map on a miss rather than waiting out the negative-cache window: a dropped release
|
|
1040
|
+
// leaves the key's home holding its grant until the delegation's own deadline, and
|
|
1041
|
+
// control entries are far too rare to drive the store.
|
|
1042
|
+
//
|
|
1043
|
+
// Inside the guard, not before it: that rebuild reads the audit store, and a throw
|
|
1044
|
+
// there would escape this sink and stall the apply loop for every later entry — the §8
|
|
1045
|
+
// rule that a receive boundary settles its callers and keeps admission closed.
|
|
1046
|
+
const author = getNodeNameForId(auditStore, event.nodeId, true);
|
|
1047
|
+
if (!author) {
|
|
1048
|
+
logger.warn?.('discarding a record lock control entry whose origin node could not be resolved');
|
|
1049
|
+
return reportDroppedWrite(event, context, new Error('Record lock control origin could not be resolved'));
|
|
1050
|
+
}
|
|
1051
|
+
// The coordinator getter fails closed on an unusable node identity. That is right for
|
|
1052
|
+
// an acquire and wrong here: rejecting out of this sink stalls the apply loop for
|
|
1053
|
+
// every later entry rather than dropping one.
|
|
1054
|
+
// `admittingCoordinator`, because `lockCoordinator` answers undefined while a transport
|
|
1055
|
+
// is momentarily unregistered — and this sink runs off the replication stream, not off
|
|
1056
|
+
// that transport. Dropping a peer's clean-handoff release there leaves the home holding
|
|
1057
|
+
// its grant for the delegation's whole deadline.
|
|
1058
|
+
target?.admittingCoordinator?.applyEntry(entry, author, event.timestamp);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
logger.warn?.('dropping a record lock control entry: the coordinator is unavailable', error);
|
|
1061
|
+
return reportDroppedWrite(event, context, error);
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
869
1064
|
// perform the write of an individual write event
|
|
870
1065
|
const writeUpdate = async (event, context) => {
|
|
1066
|
+
if (isLockControlType(event.type)) return applyLockControlEvent(event, context);
|
|
871
1067
|
const value = event.value;
|
|
872
1068
|
const Table = event.table ? databases[databaseName][event.table] : TableResource;
|
|
873
1069
|
if (
|
|
@@ -899,6 +1095,14 @@ export function makeTable(options) {
|
|
|
899
1095
|
async: true,
|
|
900
1096
|
};
|
|
901
1097
|
const id = event.id;
|
|
1098
|
+
if (!isSourceWriteType(event.type)) {
|
|
1099
|
+
logger.error?.('Unknown operation', event.type, event.id);
|
|
1100
|
+
const notification = reportDroppedWrite(event, context, new Error('Unknown source operation'));
|
|
1101
|
+
if (event.finished) await event.finished;
|
|
1102
|
+
return notification;
|
|
1103
|
+
}
|
|
1104
|
+
if (Table && event.type === 'put' && value == null && !shouldRevalidateEvents)
|
|
1105
|
+
await reportDroppedWrite(event, context, new Error('Source-applied put has no record content'));
|
|
902
1106
|
const resource: TableResource = await Table.getResource(id, context, options);
|
|
903
1107
|
if (event.finished) await event.finished;
|
|
904
1108
|
switch (event.type) {
|
|
@@ -919,13 +1123,18 @@ export function makeTable(options) {
|
|
|
919
1123
|
return resource._writeInvalidate(id, value, options);
|
|
920
1124
|
case 'relocate':
|
|
921
1125
|
return resource._writeRelocate(id, options);
|
|
922
|
-
default:
|
|
923
|
-
logger.error?.('Unknown operation', event.type, event.id);
|
|
924
1126
|
}
|
|
925
1127
|
};
|
|
926
1128
|
|
|
927
1129
|
/** Keeps the writes to any one key in arrival order; see DESIGN.md (harper#2211). */
|
|
928
1130
|
const stageWrite = (event, context) => {
|
|
1131
|
+
// A grant must not queue behind whatever the key it names is doing.
|
|
1132
|
+
if (
|
|
1133
|
+
isLockControlType(event.type) ||
|
|
1134
|
+
!isSourceWriteType(event.type) ||
|
|
1135
|
+
(event.type === 'put' && event.value == null && !shouldRevalidateEvents)
|
|
1136
|
+
)
|
|
1137
|
+
return writeUpdate(event, context);
|
|
929
1138
|
let chainKey: string | undefined;
|
|
930
1139
|
try {
|
|
931
1140
|
const Table = event.table ? databases[databaseName][event.table] : TableResource;
|
|
@@ -965,14 +1174,18 @@ export function makeTable(options) {
|
|
|
965
1174
|
omitCurrent: true,
|
|
966
1175
|
};
|
|
967
1176
|
const subscribeOnThisThread = source.subscribeOnThisThread
|
|
968
|
-
? source.subscribeOnThisThread(
|
|
969
|
-
:
|
|
1177
|
+
? source.subscribeOnThisThread(applicationWorkerIndex(), subscriptionOptions)
|
|
1178
|
+
: runsApplicationCodeSingletons(); // set up by the defining application's code, so it runs where that code does
|
|
970
1179
|
const subscription = hasSubscribe && subscribeOnThisThread && (await source.subscribe?.(subscriptionOptions));
|
|
971
1180
|
if (subscription) {
|
|
972
1181
|
let txnInProgress;
|
|
973
1182
|
// we listen for events by iterating through the async iterator provided by the subscription
|
|
974
1183
|
for await (const event of subscription) {
|
|
1184
|
+
let failureEvent = event;
|
|
1185
|
+
let failurePosition: number | undefined;
|
|
1186
|
+
let applied = false;
|
|
975
1187
|
try {
|
|
1188
|
+
failurePosition = event?.timestamp;
|
|
976
1189
|
if (!event || typeof event !== 'object') {
|
|
977
1190
|
logger.error?.('Bad subscription event', event);
|
|
978
1191
|
continue;
|
|
@@ -980,6 +1193,13 @@ export function makeTable(options) {
|
|
|
980
1193
|
const firstWrite = event.type === 'transaction' ? event.writes[0] : event;
|
|
981
1194
|
if (!firstWrite) {
|
|
982
1195
|
logger.error?.('Bad subscription event', event);
|
|
1196
|
+
await notifyReplicatedApplyFailure(
|
|
1197
|
+
databaseName,
|
|
1198
|
+
event,
|
|
1199
|
+
failurePosition,
|
|
1200
|
+
new Error('Subscription transaction has no writes'),
|
|
1201
|
+
tableName
|
|
1202
|
+
);
|
|
983
1203
|
continue;
|
|
984
1204
|
}
|
|
985
1205
|
event.source = source;
|
|
@@ -988,11 +1208,16 @@ export function makeTable(options) {
|
|
|
988
1208
|
// there is no re-subscribe / sequence-id-resume path to recover it. Mark the context so the
|
|
989
1209
|
// commit retries such conflicts without a cap (see DatabaseTransaction commit).
|
|
990
1210
|
event.sourceApply = true;
|
|
1211
|
+
event[SOURCE_APPLY_POSITION] = failurePosition;
|
|
991
1212
|
if (event.type === 'end_txn') {
|
|
992
1213
|
// Capture the in-progress transaction in a stable local: the loop variable is reset
|
|
993
1214
|
// once this transaction completes (below), but the seq-id closure and the commit await
|
|
994
1215
|
// still need to reference it afterward.
|
|
995
1216
|
const committingTxn = txnInProgress;
|
|
1217
|
+
if (committingTxn) {
|
|
1218
|
+
failureEvent = committingTxn;
|
|
1219
|
+
failurePosition = committingTxn[SOURCE_APPLY_POSITION];
|
|
1220
|
+
}
|
|
996
1221
|
committingTxn?.resolve();
|
|
997
1222
|
let updateRecordedSequenceId: () => MaybePromise<void>;
|
|
998
1223
|
if (event.localTime && lastSequenceId !== event.localTime) {
|
|
@@ -1082,6 +1307,7 @@ export function makeTable(options) {
|
|
|
1082
1307
|
let committed;
|
|
1083
1308
|
try {
|
|
1084
1309
|
committed = committingTxn ? await committingTxn.committed : undefined;
|
|
1310
|
+
applied = true;
|
|
1085
1311
|
if (event.onCommit) {
|
|
1086
1312
|
// the onCommit callback can be async and carry associated work (e.g. blob
|
|
1087
1313
|
// transfer); wait for it too before recording the sequence id. Pass the commit
|
|
@@ -1115,6 +1341,13 @@ export function makeTable(options) {
|
|
|
1115
1341
|
// than rethrow) so the current beginTxn still starts a fresh transaction with
|
|
1116
1342
|
// correct boundaries instead of having its writes applied as standalone ones.
|
|
1117
1343
|
logger.error?.('source-applied transaction commit failed during apply', error);
|
|
1344
|
+
await notifyReplicatedApplyFailure(
|
|
1345
|
+
databaseName,
|
|
1346
|
+
txnInProgress,
|
|
1347
|
+
txnInProgress[SOURCE_APPLY_POSITION],
|
|
1348
|
+
error,
|
|
1349
|
+
tableName
|
|
1350
|
+
);
|
|
1118
1351
|
} finally {
|
|
1119
1352
|
// Clear it regardless of outcome so a rejected commit isn't re-awaited on the
|
|
1120
1353
|
// next beginTxn (which would brick the apply loop).
|
|
@@ -1197,6 +1430,7 @@ export function makeTable(options) {
|
|
|
1197
1430
|
// standalone write: backpressure on the commit before pulling the next event,
|
|
1198
1431
|
// and pass the commit resolution through to the callback.
|
|
1199
1432
|
const committed = commitResolution ? await commitResolution : undefined;
|
|
1433
|
+
applied = true;
|
|
1200
1434
|
await event.onCommit(committed);
|
|
1201
1435
|
}
|
|
1202
1436
|
} else if (commitResolution && !txnInProgress) {
|
|
@@ -1205,6 +1439,14 @@ export function makeTable(options) {
|
|
|
1205
1439
|
}
|
|
1206
1440
|
} catch (error) {
|
|
1207
1441
|
logger.error?.('error in subscription handler', error);
|
|
1442
|
+
if (!applied)
|
|
1443
|
+
await notifyReplicatedApplyFailure(databaseName, failureEvent, failurePosition, error, tableName);
|
|
1444
|
+
} finally {
|
|
1445
|
+
while (pendingApplyFailures) {
|
|
1446
|
+
const notification = pendingApplyFailures;
|
|
1447
|
+
pendingApplyFailures = undefined;
|
|
1448
|
+
await notification;
|
|
1449
|
+
}
|
|
1208
1450
|
}
|
|
1209
1451
|
}
|
|
1210
1452
|
}
|
|
@@ -1498,24 +1740,51 @@ export function makeTable(options) {
|
|
|
1498
1740
|
* This also informs the scheduling for record eviction.
|
|
1499
1741
|
* @param opts Time in seconds until records expire, or an options object with `expiration`, `eviction`,
|
|
1500
1742
|
* and `scanInterval` (all in seconds, all optional). Number form preserves any previously configured
|
|
1501
|
-
* eviction/scanInterval; object form replaces all three.
|
|
1743
|
+
* eviction/scanInterval; object form replaces all three. An internal schema ownership-only call with
|
|
1744
|
+
* none of those values preserves the settings already loaded from the catalog.
|
|
1502
1745
|
*/
|
|
1503
|
-
static setTTLExpiration(
|
|
1746
|
+
static setTTLExpiration(
|
|
1747
|
+
opts:
|
|
1748
|
+
| number
|
|
1749
|
+
| {
|
|
1750
|
+
expiration?: number;
|
|
1751
|
+
eviction?: number;
|
|
1752
|
+
scanInterval?: number;
|
|
1753
|
+
fromSchema?: boolean;
|
|
1754
|
+
isolatedApplicationOwner?: boolean;
|
|
1755
|
+
}
|
|
1756
|
+
) {
|
|
1504
1757
|
if (opts == null || (typeof opts !== 'number' && typeof opts !== 'object'))
|
|
1505
1758
|
throw new Error('Invalid expiration value type');
|
|
1759
|
+
const declaredHere = typeof opts === 'object' && opts.fromSchema;
|
|
1760
|
+
const isolatedApplicationOwner = declaredHere && opts.isolatedApplicationOwner;
|
|
1761
|
+
const preserveLoadedConfiguration =
|
|
1762
|
+
declaredHere && opts.expiration === undefined && opts.eviction === undefined && opts.scanInterval === undefined;
|
|
1763
|
+
if (((!ttlFromLoad && !declaredHere) || isolatedApplicationOwner) && !ttlConfiguredByApplication) {
|
|
1764
|
+
ttlConfiguredByApplication = true;
|
|
1765
|
+
// the scan owner may have changed with this: re-evaluate even if the interval did not
|
|
1766
|
+
lastCleanupInterval = undefined;
|
|
1767
|
+
}
|
|
1506
1768
|
if (typeof opts === 'number') {
|
|
1507
1769
|
expirationMs = opts * 1000;
|
|
1508
|
-
} else {
|
|
1770
|
+
} else if (!preserveLoadedConfiguration) {
|
|
1509
1771
|
// `??` so an explicit 0 is treated as the user's chosen value, not as "missing"
|
|
1510
1772
|
expirationMs = (opts.expiration ?? 0) * 1000;
|
|
1511
1773
|
evictionMs = (opts.eviction ?? 0) * 1000;
|
|
1512
1774
|
cleanupInterval = (opts.scanInterval ?? 0) * 1000;
|
|
1513
1775
|
}
|
|
1514
1776
|
if (expirationMs < 0) throw new Error('Expiration can not be negative');
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1777
|
+
if (!preserveLoadedConfiguration) {
|
|
1778
|
+
// default to one quarter of the total expiration+eviction window
|
|
1779
|
+
cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
|
|
1780
|
+
expirationScanScheduled = true;
|
|
1781
|
+
}
|
|
1782
|
+
// Re-evaluate an existing table-level scan after an ownership-only declaration, but do not
|
|
1783
|
+
// create the default daily cleanup timer for a table that has only an @expiresAt field.
|
|
1784
|
+
if (!preserveLoadedConfiguration || expirationScanScheduled || evictionMs) scheduleCleanup();
|
|
1785
|
+
// @expiresAt has its own interval rather than the cleanup timer above. Arm it whenever a live
|
|
1786
|
+
// declaration introduces the attribute, including after this application already claimed TTL.
|
|
1787
|
+
if (expiresAtProperty && !recordExpirationInterval) runRecordExpirationEviction();
|
|
1519
1788
|
}
|
|
1520
1789
|
|
|
1521
1790
|
static getResidencyRecord(id: Id) {
|
|
@@ -1622,6 +1891,43 @@ export function makeTable(options) {
|
|
|
1622
1891
|
static async dropTable() {
|
|
1623
1892
|
TableResource.assertSchemaMutable('drop a table');
|
|
1624
1893
|
const rootStore = primaryStore.rootStore;
|
|
1894
|
+
if (
|
|
1895
|
+
databaseName === databasePath &&
|
|
1896
|
+
rootStore instanceof RocksDatabase &&
|
|
1897
|
+
(dbisDb as any).put !== (dbisDb as any).putSync
|
|
1898
|
+
)
|
|
1899
|
+
throw new Error(
|
|
1900
|
+
`Cannot drop ${databaseName}.${TableResource.tableName}: the catalog store's put is asynchronous, so the drop tombstone cannot be made durable before the column families are dropped`
|
|
1901
|
+
);
|
|
1902
|
+
// Release post-commit derived-index delivery before any destructive work: the runner's
|
|
1903
|
+
// backend must have quiesced before its stores and native file are destroyed, and a
|
|
1904
|
+
// same-name recreate must not race an owner still applying to the old generation.
|
|
1905
|
+
const derivedIndexRuntime = TableResource.derivedIndexRuntime;
|
|
1906
|
+
const restoreDerivedIndexesAfterFailedDrop = () => {
|
|
1907
|
+
try {
|
|
1908
|
+
TableResource.derivedIndexRuntime = derivedIndexRuntime?.restoreAfterFailedDrop?.();
|
|
1909
|
+
} catch (restoreError) {
|
|
1910
|
+
TableResource.derivedIndexRuntime = undefined;
|
|
1911
|
+
logger.error?.(
|
|
1912
|
+
`Could not restore derived indexes after failed drop of ${databaseName}.${TableResource.tableName}`,
|
|
1913
|
+
restoreError
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
try {
|
|
1918
|
+
await derivedIndexRuntime?.close(true);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
restoreDerivedIndexesAfterFailedDrop();
|
|
1921
|
+
throw error;
|
|
1922
|
+
}
|
|
1923
|
+
const abortStaleDrop = () => {
|
|
1924
|
+
derivedIndexRuntime?.completeDrop?.(false);
|
|
1925
|
+
TableResource.derivedIndexRuntime = undefined;
|
|
1926
|
+
TableResource.cleanup();
|
|
1927
|
+
if (databases[databaseName]?.[tableName] === TableResource) delete databases[databaseName][tableName];
|
|
1928
|
+
};
|
|
1929
|
+
let dropIdentityConfirmed = databaseName !== databasePath;
|
|
1930
|
+
let primaryCatalogKey = TableResource.tableName + '/';
|
|
1625
1931
|
if (databaseName === databasePath) {
|
|
1626
1932
|
// Persist a drop tombstone on the primary catalog entry BEFORE any
|
|
1627
1933
|
// destructive work. If the process dies or a column family drop fails
|
|
@@ -1629,10 +1935,19 @@ export function makeTable(options) {
|
|
|
1629
1935
|
// the next startup (or a same-name create) completes the drop via
|
|
1630
1936
|
// completeInterruptedDrop in databases.ts instead of resurrecting
|
|
1631
1937
|
// the table.
|
|
1632
|
-
|
|
1938
|
+
let tombstoneWrite: any;
|
|
1633
1939
|
const writeTombstone = () => {
|
|
1634
|
-
|
|
1635
|
-
if (!primaryMeta
|
|
1940
|
+
let primaryMeta = (dbisDb as any).getSync(primaryCatalogKey);
|
|
1941
|
+
if (!primaryMeta && primaryKey) {
|
|
1942
|
+
const legacyPrimaryKey = `${TableResource.tableName}/${primaryKey}`;
|
|
1943
|
+
const legacyPrimaryMeta = (dbisDb as any).getSync(legacyPrimaryKey);
|
|
1944
|
+
if (legacyPrimaryMeta?.isPrimaryKey) {
|
|
1945
|
+
primaryCatalogKey = legacyPrimaryKey;
|
|
1946
|
+
primaryMeta = legacyPrimaryMeta;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
if (!primaryMeta || (primaryMeta.tableId != null && primaryMeta.tableId !== tableId)) return false;
|
|
1950
|
+
if (primaryMeta.dropping) return true;
|
|
1636
1951
|
primaryMeta.dropping = true;
|
|
1637
1952
|
// Stamps this drop's identity so the interrupted-drop retry budget in
|
|
1638
1953
|
// databases.ts can be scoped to THIS drop rather than the table name: a
|
|
@@ -1642,30 +1957,34 @@ export function makeTable(options) {
|
|
|
1642
1957
|
// the budget by generation instead makes the new drop's tombstone carry
|
|
1643
1958
|
// its own fresh key regardless of what any worker last observed.
|
|
1644
1959
|
primaryMeta.dropGeneration = randomUUID();
|
|
1645
|
-
|
|
1960
|
+
tombstoneWrite = (dbisDb as any).put(primaryCatalogKey, primaryMeta);
|
|
1961
|
+
return true;
|
|
1646
1962
|
};
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1963
|
+
try {
|
|
1964
|
+
if (rootStore instanceof RocksDatabase) {
|
|
1965
|
+
// withUpdateAttributesLock's locked section cannot be held across an await, so a durable
|
|
1966
|
+
// tombstone depends on put being rebound to putSync for RocksDB primary stores.
|
|
1967
|
+
dropIdentityConfirmed = withUpdateAttributesLock(
|
|
1968
|
+
rootStore,
|
|
1969
|
+
`drop table '${databaseName}.${TableResource.tableName}'`,
|
|
1970
|
+
writeTombstone
|
|
1655
1971
|
);
|
|
1656
|
-
|
|
1657
|
-
rootStore
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
});
|
|
1666
|
-
if (typeof tombstoneWrite?.then === 'function') await tombstoneWrite;
|
|
1972
|
+
} else {
|
|
1973
|
+
rootStore.transactionSync(() => {
|
|
1974
|
+
dropIdentityConfirmed = writeTombstone();
|
|
1975
|
+
});
|
|
1976
|
+
if (typeof tombstoneWrite?.then === 'function') await tombstoneWrite;
|
|
1977
|
+
}
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
restoreDerivedIndexesAfterFailedDrop();
|
|
1980
|
+
throw error;
|
|
1667
1981
|
}
|
|
1668
1982
|
}
|
|
1983
|
+
if (!dropIdentityConfirmed) {
|
|
1984
|
+
abortStaleDrop();
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
TableResource.derivedIndexRuntime = undefined;
|
|
1669
1988
|
// A get() against a sourcedFrom table resolves to its caller before the resolved
|
|
1670
1989
|
// record's cache write has committed (see getFromSource) - the write lands "in the
|
|
1671
1990
|
// background" for latency reasons. Flip this BEFORE removing the table from the
|
|
@@ -1678,7 +1997,7 @@ export function makeTable(options) {
|
|
|
1678
1997
|
// family drops below. If a drop fails past this point the table stays
|
|
1679
1998
|
// invisible, and the tombstone guarantees the drop completes on the
|
|
1680
1999
|
// next startup (or on a same-name create).
|
|
1681
|
-
delete databases[databaseName][tableName];
|
|
2000
|
+
if (databases[databaseName]?.[tableName] === TableResource) delete databases[databaseName][tableName];
|
|
1682
2001
|
// The above stops new source-fill writes from starting, but a write from a get()
|
|
1683
2002
|
// that already returned to its caller may still be in flight. Dropping the column
|
|
1684
2003
|
// families out from under that write is a genuine invariant violation, not just a
|
|
@@ -1709,15 +2028,21 @@ export function makeTable(options) {
|
|
|
1709
2028
|
]);
|
|
1710
2029
|
clearTimeout(timer);
|
|
1711
2030
|
if (result === timedOut) {
|
|
2031
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
1712
2032
|
throw new Error(
|
|
1713
2033
|
`dropTable() timed out after ${LOCK_TIMEOUT}ms waiting for ${pending.length} in-flight source-populated cache write(s) on ${tableName} to settle; refusing to drop the column families out from under a write that may still be staged. The drop tombstone is durable, so this will be retried on the next load.`
|
|
1714
2034
|
);
|
|
1715
2035
|
}
|
|
1716
2036
|
}
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2037
|
+
try {
|
|
2038
|
+
for (const entry of primaryStore.getRange({ versions: true, snapshot: false, lazy: true })) {
|
|
2039
|
+
if (entry.metadataFlags & HAS_BLOBS && entry.value) {
|
|
2040
|
+
deleteBlobsInObject(entry.value);
|
|
2041
|
+
}
|
|
1720
2042
|
}
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
2045
|
+
throw error;
|
|
1721
2046
|
}
|
|
1722
2047
|
if (databaseName === databasePath) {
|
|
1723
2048
|
// part of a database.
|
|
@@ -1735,12 +2060,13 @@ export function makeTable(options) {
|
|
|
1735
2060
|
// same-name create completes the interrupted drop and writes fresh
|
|
1736
2061
|
// catalog rows, and clobbering those would orphan the new table.
|
|
1737
2062
|
const removeTombstonedCatalog = () => {
|
|
1738
|
-
const currentPrimary = (dbisDb as any).getSync(
|
|
1739
|
-
if (!currentPrimary?.dropping
|
|
2063
|
+
const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
|
|
2064
|
+
if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
|
|
2065
|
+
return false;
|
|
1740
2066
|
for (const attribute of attributes) {
|
|
1741
2067
|
dbisDb.remove(TableResource.tableName + '/' + attribute.name);
|
|
1742
2068
|
}
|
|
1743
|
-
dbisDb.remove(
|
|
2069
|
+
dbisDb.remove(primaryCatalogKey);
|
|
1744
2070
|
return true;
|
|
1745
2071
|
};
|
|
1746
2072
|
if (rootStore instanceof RocksDatabase) {
|
|
@@ -1752,47 +2078,88 @@ export function makeTable(options) {
|
|
|
1752
2078
|
// completeInterruptedDrop does), never an awaited drop(), or a
|
|
1753
2079
|
// concurrent create's wait would be stuck on a drop that the blocked
|
|
1754
2080
|
// event loop can never resolve, burning its full deadline before failing.
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
2081
|
+
let removed: boolean;
|
|
2082
|
+
try {
|
|
2083
|
+
removed = withUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`, () => {
|
|
2084
|
+
const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
|
|
2085
|
+
if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
|
|
2086
|
+
return false;
|
|
2087
|
+
for (const attribute of attributes) {
|
|
2088
|
+
const index = indices[attribute.name];
|
|
2089
|
+
if (index)
|
|
2090
|
+
try {
|
|
2091
|
+
index.customIndex?.resetDerivedStorage?.();
|
|
2092
|
+
index.dropSync();
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
ignoreAlreadyDropped(error);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
try {
|
|
2098
|
+
primaryStore.dropSync();
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
ignoreAlreadyDropped(error);
|
|
2101
|
+
}
|
|
2102
|
+
return removeTombstonedCatalog();
|
|
2103
|
+
});
|
|
2104
|
+
if (removed) await dbisDb.committed;
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
2107
|
+
throw error;
|
|
2108
|
+
}
|
|
2109
|
+
if (!removed) {
|
|
2110
|
+
abortStaleDrop();
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
1773
2113
|
} else {
|
|
1774
2114
|
// LMDB: no shared column-family double-drop, and its engine lock is
|
|
1775
2115
|
// transactional rather than this spin lock, so keep the awaited drop
|
|
1776
2116
|
// plus the same tombstone-guarded catalog removal.
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
const
|
|
1780
|
-
if (
|
|
2117
|
+
let removed: boolean;
|
|
2118
|
+
try {
|
|
2119
|
+
const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
|
|
2120
|
+
if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId)) {
|
|
2121
|
+
abortStaleDrop();
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const drops = [];
|
|
2125
|
+
for (const attribute of attributes) {
|
|
2126
|
+
const index = indices[attribute.name];
|
|
2127
|
+
if (index) {
|
|
2128
|
+
index.customIndex?.resetDerivedStorage?.();
|
|
2129
|
+
drops.push(index.drop().catch(ignoreAlreadyDropped));
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
|
|
2133
|
+
await Promise.all(drops);
|
|
2134
|
+
removed = removeTombstonedCatalog();
|
|
2135
|
+
if (removed) await dbisDb.committed;
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
2138
|
+
throw error;
|
|
2139
|
+
}
|
|
2140
|
+
if (!removed) {
|
|
2141
|
+
abortStaleDrop();
|
|
2142
|
+
throw new Error(
|
|
2143
|
+
`Could not complete drop of ${databaseName}.${tableName}: a replacement table became current while the LMDB stores were being dropped`
|
|
2144
|
+
);
|
|
1781
2145
|
}
|
|
1782
|
-
drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
|
|
1783
|
-
await Promise.all(drops);
|
|
1784
|
-
if (removeTombstonedCatalog()) await dbisDb.committed;
|
|
1785
2146
|
}
|
|
1786
2147
|
} else {
|
|
1787
2148
|
// legacy table per database. The store to retire is this table's own audit store: nothing
|
|
1788
2149
|
// assigns `primaryStore.auditStore` — openAuditStore() assigns `rootStore.auditStore`, and
|
|
1789
2150
|
// this is the reference makeTable() was handed. Awaited so a pass suspended mid-removal has
|
|
1790
2151
|
// released the primary DBI before it is closed and unlinked.
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
2152
|
+
try {
|
|
2153
|
+
await auditStore?.stopAuditCleanup?.();
|
|
2154
|
+
removeStorageReclamation(primaryStore.path);
|
|
2155
|
+
await primaryStore.close();
|
|
2156
|
+
fs.unlinkSync(primaryStore.path);
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
2159
|
+
throw error;
|
|
2160
|
+
}
|
|
1795
2161
|
}
|
|
2162
|
+
derivedIndexRuntime?.completeDrop?.();
|
|
1796
2163
|
signalling.signalSchemaChange(
|
|
1797
2164
|
new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName)
|
|
1798
2165
|
);
|
|
@@ -1815,8 +2182,7 @@ export function makeTable(options) {
|
|
|
1815
2182
|
records: './', // an href to the records themselves
|
|
1816
2183
|
name: tableName,
|
|
1817
2184
|
database: databaseName,
|
|
1818
|
-
auditSize:
|
|
1819
|
-
auditStore instanceof RocksDatabase ? auditStore.getKeysCount() : auditStore?.getStats().entryCount,
|
|
2185
|
+
auditSize: auditStore?.getStats().entryCount,
|
|
1820
2186
|
attributes,
|
|
1821
2187
|
recordCount: undefined,
|
|
1822
2188
|
estimatedRecordRange: undefined,
|
|
@@ -2054,12 +2420,18 @@ export function makeTable(options) {
|
|
|
2054
2420
|
} else {
|
|
2055
2421
|
id = requestTargetToId(target);
|
|
2056
2422
|
}
|
|
2423
|
+
if (this.#writeGeneration?.closed) {
|
|
2424
|
+
this.#changes = undefined;
|
|
2425
|
+
this.#writeGeneration = undefined;
|
|
2426
|
+
}
|
|
2427
|
+
this.#assertLiveHandle(id, true);
|
|
2057
2428
|
|
|
2058
2429
|
const context = this.getContext();
|
|
2059
2430
|
const envTxn = txnForContext(context);
|
|
2060
2431
|
if (!envTxn) throw new Error('Can not update a table resource outside of a transaction');
|
|
2061
2432
|
// record in the list of updating records so it can be written to the database when we commit
|
|
2062
|
-
|
|
2433
|
+
// `false` is the patch-cancel sentinel, not a record root — but only incrementally.
|
|
2434
|
+
if (updates === false && !fullUpdate) {
|
|
2063
2435
|
// TODO: Remove from transaction
|
|
2064
2436
|
return this;
|
|
2065
2437
|
}
|
|
@@ -2102,15 +2474,25 @@ export function makeTable(options) {
|
|
|
2102
2474
|
});
|
|
2103
2475
|
}
|
|
2104
2476
|
}
|
|
2105
|
-
|
|
2477
|
+
// Keep absent changes distinguishable from an explicit empty patch: framework-created
|
|
2478
|
+
// post/publish updates do not necessarily mutate or save the instance.
|
|
2479
|
+
// A supplied root must reach validation as itself, not as the staged changes (harper#1298).
|
|
2480
|
+
const recordRoot = updates === undefined ? this.#changes : updates;
|
|
2481
|
+
return when(this._writeUpdate(id, recordRoot, fullUpdate), () => this);
|
|
2106
2482
|
}
|
|
2107
2483
|
|
|
2108
2484
|
/**
|
|
2109
2485
|
* Save any changes into this instance to the current transaction
|
|
2110
2486
|
*/
|
|
2111
2487
|
save() {
|
|
2112
|
-
this.#assertLiveHandle(this.getId()); // a write through a released or expired lock never lands
|
|
2113
2488
|
const operation = this.#savingOperation;
|
|
2489
|
+
if (
|
|
2490
|
+
!this.#lockWritable &&
|
|
2491
|
+
this.#writeGeneration?.closed &&
|
|
2492
|
+
(!operation || operation.writeGeneration === this.#writeGeneration)
|
|
2493
|
+
)
|
|
2494
|
+
return;
|
|
2495
|
+
this.#assertLiveHandle(operation?.key ?? this.getId()); // a write through a released or expired lock never lands
|
|
2114
2496
|
if ((!operation || operation.dropped) && this.#lockWritable && this.#lockHandle?.hold) {
|
|
2115
2497
|
// A held lock's record stages its update here rather than at lock() time: it is often
|
|
2116
2498
|
// written after the acquiring transaction has already completed, which would have
|
|
@@ -2122,7 +2504,7 @@ export function makeTable(options) {
|
|
|
2122
2504
|
// released between lock acquisition and this save(), throw 409 rather than silently
|
|
2123
2505
|
// committing stale data. Every lock-writable instance carries its own handle.
|
|
2124
2506
|
const saveHandle = this.#lockHandle!;
|
|
2125
|
-
if (saveHandle.
|
|
2507
|
+
if (saveHandle.isExpired()) {
|
|
2126
2508
|
throw lockNotHeldError(saveHandle);
|
|
2127
2509
|
}
|
|
2128
2510
|
const changes = this.#changes;
|
|
@@ -2170,12 +2552,21 @@ export function makeTable(options) {
|
|
|
2170
2552
|
// resolve before that native commit actually settles. Chain on innerCommit (as the
|
|
2171
2553
|
// lock-writable hold branch above already does) so callers awaiting save() see the
|
|
2172
2554
|
// write durably land, not just the outer (possibly premature) resolution.
|
|
2173
|
-
|
|
2555
|
+
let result;
|
|
2556
|
+
try {
|
|
2557
|
+
result = this.#saveOperation(operation);
|
|
2558
|
+
} catch (error) {
|
|
2559
|
+
if (!operation.saved) this.#savingOperation = operation;
|
|
2560
|
+
throw error;
|
|
2561
|
+
}
|
|
2174
2562
|
const innerCommit = operation.innerCommit;
|
|
2175
2563
|
return innerCommit ? when(innerCommit, () => result) : result;
|
|
2176
2564
|
}
|
|
2177
2565
|
}
|
|
2178
2566
|
#saveOperation(operation: any) {
|
|
2567
|
+
// LMDB validates staged writes at transaction commit, so bind a lazy update to the
|
|
2568
|
+
// generation selected by save() before another update can replace its changes.
|
|
2569
|
+
operation.captureChanges?.();
|
|
2179
2570
|
const transaction = txnForContext(this.getContext());
|
|
2180
2571
|
const holder = operation.stagedIn;
|
|
2181
2572
|
// never-drop-on-conflict lives on the transaction and would not travel with the write, so an
|
|
@@ -2195,13 +2586,26 @@ export function makeTable(options) {
|
|
|
2195
2586
|
// merge and index diff would be relative to a record that may never land.
|
|
2196
2587
|
operation.priorWrite = undefined;
|
|
2197
2588
|
operation.deferSave = false;
|
|
2198
|
-
|
|
2589
|
+
const result = when(transaction.addWrite(operation), () => operation.promise ?? operation.result);
|
|
2590
|
+
this.#closeWriteChain(operation);
|
|
2591
|
+
return result;
|
|
2199
2592
|
}
|
|
2200
2593
|
const owner = holder ?? transaction;
|
|
2201
|
-
if (owner.save)
|
|
2594
|
+
if (owner.save) {
|
|
2595
|
+
const result = owner.save(operation) || operation.promise || operation.result;
|
|
2596
|
+
this.#closeWriteChain(operation);
|
|
2597
|
+
return result;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
#closeWriteChain(operation: any) {
|
|
2601
|
+
const owner = operation.stagedIn;
|
|
2602
|
+
for (let write = operation; write && !write.instanceClosed; write = write.priorWrite) {
|
|
2603
|
+
if (write === operation || owner?.ownedWrites?.has(write)) closeWriteInstance(write);
|
|
2604
|
+
}
|
|
2202
2605
|
}
|
|
2203
2606
|
|
|
2204
2607
|
addTo(property: any, value: any) {
|
|
2608
|
+
this[ASSERT_TRACKED_WRITABLE]();
|
|
2205
2609
|
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
2206
2610
|
if (this.#savingOperation?.fullUpdate)
|
|
2207
2611
|
(this as any).set(property, (+this.getProperty(property) || 0) + (value as any));
|
|
@@ -2255,6 +2659,7 @@ export function makeTable(options) {
|
|
|
2255
2659
|
const context = this.getContext();
|
|
2256
2660
|
checkValidId(id);
|
|
2257
2661
|
const transaction = txnForContext(this.getContext());
|
|
2662
|
+
assertDerivedIndexAdmission(options, transaction);
|
|
2258
2663
|
const write: any = {
|
|
2259
2664
|
key: id,
|
|
2260
2665
|
store: primaryStore,
|
|
@@ -2262,6 +2667,7 @@ export function makeTable(options) {
|
|
|
2262
2667
|
entry: this.#entry,
|
|
2263
2668
|
recordVersion: options?.version,
|
|
2264
2669
|
lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
|
|
2670
|
+
reloadCommitBase: true,
|
|
2265
2671
|
commit: (txnTime, existingEntry, _retry, transaction: any) => {
|
|
2266
2672
|
const txnLogKey =
|
|
2267
2673
|
isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
|
|
@@ -2313,6 +2719,7 @@ export function makeTable(options) {
|
|
|
2313
2719
|
const context = this.getContext();
|
|
2314
2720
|
checkValidId(id);
|
|
2315
2721
|
const transaction = txnForContext(this.getContext());
|
|
2722
|
+
assertDerivedIndexAdmission(options, transaction);
|
|
2316
2723
|
const write: any = {
|
|
2317
2724
|
key: id,
|
|
2318
2725
|
store: primaryStore,
|
|
@@ -2320,6 +2727,7 @@ export function makeTable(options) {
|
|
|
2320
2727
|
entry: this.#entry,
|
|
2321
2728
|
recordVersion: options?.version,
|
|
2322
2729
|
lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
|
|
2730
|
+
reloadCommitBase: true,
|
|
2323
2731
|
before:
|
|
2324
2732
|
(this.constructor as any).source?.relocate && !(context as any)?.source
|
|
2325
2733
|
? (this.constructor as any).source.relocate.bind((this.constructor as any).source, id, undefined, context)
|
|
@@ -2428,9 +2836,8 @@ export function makeTable(options) {
|
|
|
2428
2836
|
// if there is a resolution in-progress, abandon the eviction
|
|
2429
2837
|
if (primaryStore.hasLock(id, entry.version)) return;
|
|
2430
2838
|
}
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
2433
|
-
// removed the entry entirely, but first cleanup indices
|
|
2839
|
+
// Eviction is not a canonical delete. Indexed caching tables add a local-only control entry so
|
|
2840
|
+
// their derived indexes can remove the resident projection without exposing a delete event.
|
|
2434
2841
|
let lmdbCompletion: MaybePromise<unknown>;
|
|
2435
2842
|
if (primaryStore.ifVersion) {
|
|
2436
2843
|
// lmdb: the index cleanup and the record removal are both version-guarded optimistic writes.
|
|
@@ -2443,6 +2850,7 @@ export function makeTable(options) {
|
|
|
2443
2850
|
lmdbCompletion = Promise.all([indexCleanup, removal]);
|
|
2444
2851
|
} else {
|
|
2445
2852
|
updateIndices(id, existingRecord, null, options);
|
|
2853
|
+
stageDerivedIndexEviction(transaction as RocksTransaction, id, existingVersion);
|
|
2446
2854
|
removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), options);
|
|
2447
2855
|
}
|
|
2448
2856
|
committed = true;
|
|
@@ -2535,13 +2943,29 @@ export function makeTable(options) {
|
|
|
2535
2943
|
}
|
|
2536
2944
|
const id = target != null ? requestTargetToId(target as RequestTargetOrId) : this.getId();
|
|
2537
2945
|
checkValidId(id);
|
|
2946
|
+
this.#assertLiveHandle(id);
|
|
2538
2947
|
const resolved = resolveLockOptions(options);
|
|
2539
2948
|
const context = this.getContext();
|
|
2540
2949
|
const link = txnForContext(context);
|
|
2541
2950
|
const keyId = writeKeyId(id);
|
|
2951
|
+
// Before the re-entrant paths, not after: a transaction that already holds this key
|
|
2952
|
+
// node-scoped would otherwise be handed that handle back for an explicit cluster request,
|
|
2953
|
+
// while the same request on a fresh key fails closed.
|
|
2954
|
+
if (
|
|
2955
|
+
resolved.scope === 'cluster' &&
|
|
2956
|
+
(resolved.scopeRequested || isClusterLockRequired(databaseName)) &&
|
|
2957
|
+
!getClusterLockTransport(databaseName)
|
|
2958
|
+
)
|
|
2959
|
+
return Promise.reject(
|
|
2960
|
+
new LockUnavailableError(
|
|
2961
|
+
`Cluster-scoped record locks are not available on ${databaseName}: no record lock transport is registered`
|
|
2962
|
+
)
|
|
2963
|
+
);
|
|
2542
2964
|
const held = this.#lockHandle;
|
|
2543
|
-
if (held && !held.
|
|
2965
|
+
if (held && !held.isExpired() && held.keyId === keyId) {
|
|
2544
2966
|
// Re-entrant: upgrade to hold if requested, then preserve staged changes.
|
|
2967
|
+
const violation = scopeViolation(held, resolved, databaseName);
|
|
2968
|
+
if (violation) return Promise.reject(violation);
|
|
2545
2969
|
if (resolved.hold && !held.hold) {
|
|
2546
2970
|
held.upgradeToHold(resolved.lease);
|
|
2547
2971
|
// The scoped phase eagerly staged a TransactionWrite (see #reloadLocked); hold
|
|
@@ -2555,7 +2979,9 @@ export function makeTable(options) {
|
|
|
2555
2979
|
return Promise.resolve(this.#reloadLocked(id, undefined, true));
|
|
2556
2980
|
}
|
|
2557
2981
|
const scoped = link.recordLockFor(primaryStore, keyId);
|
|
2558
|
-
if (scoped && !scoped.
|
|
2982
|
+
if (scoped && !scoped.isExpired()) {
|
|
2983
|
+
const violation = scopeViolation(scoped, resolved, databaseName);
|
|
2984
|
+
if (violation) return Promise.reject(violation);
|
|
2559
2985
|
if (resolved.hold && !scoped.hold) {
|
|
2560
2986
|
// Upgrade scoped → hold: flip the existing handle object to hold mode so every
|
|
2561
2987
|
// instance that already references this handle stays valid. Retiring and creating a
|
|
@@ -2568,6 +2994,17 @@ export function makeTable(options) {
|
|
|
2568
2994
|
// Already held with the same type: re-entrant return. Preserve any staged changes.
|
|
2569
2995
|
return Promise.resolve(this.#reloadLocked(id, scoped, true));
|
|
2570
2996
|
}
|
|
2997
|
+
// Cluster scope needs a registered transport. An EXPLICIT { scope: 'cluster' } without one is
|
|
2998
|
+
// a caller asking for a guarantee this node cannot make, so it fails closed rather than
|
|
2999
|
+
// silently returning the node-local lock; the default keeps Phase 0 behavior, which is what
|
|
3000
|
+
// a build with no replication has anyway.
|
|
3001
|
+
// The getter fails closed on an unusable node identity, and lock() answers with a promise.
|
|
3002
|
+
let coordinator: LockCoordinator | undefined;
|
|
3003
|
+
try {
|
|
3004
|
+
coordinator = resolved.scope === 'node' ? undefined : TableResource.lockCoordinator;
|
|
3005
|
+
} catch (error) {
|
|
3006
|
+
return Promise.reject(error as Error);
|
|
3007
|
+
}
|
|
2571
3008
|
const key = lockAttemptKey(tableId, id);
|
|
2572
3009
|
// Coalesce concurrent lock() calls for the same key inside one link so they don't
|
|
2573
3010
|
// self-block: Promise.all([T.lock(id), T.lock(id)]) would otherwise have both calls
|
|
@@ -2580,6 +3017,10 @@ export function makeTable(options) {
|
|
|
2580
3017
|
// The follower waits on the leader's acquisition, but only for its own timeout.
|
|
2581
3018
|
let followerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2582
3019
|
const followerTimedOut = Symbol('follower timeout');
|
|
3020
|
+
// Why the leader failed, so the follower can report that instead of inventing contention
|
|
3021
|
+
// when its own budget runs out. A leader 503 means the guarantee could not be established
|
|
3022
|
+
// at all; retrying is still right (the condition may clear) but 423 at the end is not.
|
|
3023
|
+
let leaderFailure: Error | undefined;
|
|
2583
3024
|
const followerStart = Date.now();
|
|
2584
3025
|
const followerDeadline = new Promise<never>((_, reject) => {
|
|
2585
3026
|
followerTimer = setTimeout(() => reject(followerTimedOut), resolved.timeout).unref();
|
|
@@ -2593,14 +3034,25 @@ export function makeTable(options) {
|
|
|
2593
3034
|
if (link.open === TRANSACTION_STATE.CLOSED && !link.saveCommits)
|
|
2594
3035
|
throw new ServerError('Transaction was closed while waiting for a record lock', 500);
|
|
2595
3036
|
const remaining = resolved.timeout - (Date.now() - followerStart);
|
|
2596
|
-
if (remaining <= 0)
|
|
2597
|
-
|
|
3037
|
+
if (remaining <= 0)
|
|
3038
|
+
throw leaderFailure ?? new ClientError(`Record is locked and was not released in time`, 423);
|
|
3039
|
+
// Carry the scope only if the caller named it: spreading the resolved options would turn
|
|
3040
|
+
// a defaulted 'cluster' into an explicit one, which is fail-closed when no transport is
|
|
3041
|
+
// registered.
|
|
3042
|
+
return this.lock(target, {
|
|
3043
|
+
lease: resolved.lease,
|
|
3044
|
+
timeout: remaining,
|
|
3045
|
+
hold: resolved.hold,
|
|
3046
|
+
scope: resolved.scopeRequested ? resolved.scope : undefined,
|
|
3047
|
+
}) as Promise<any>;
|
|
2598
3048
|
};
|
|
2599
3049
|
return Promise.race([pending, followerDeadline]).then(
|
|
2600
3050
|
() => {
|
|
2601
3051
|
clearTimeout(followerTimer);
|
|
2602
3052
|
const acquired = link.recordLockFor(primaryStore, keyId);
|
|
2603
|
-
if (acquired && !acquired.
|
|
3053
|
+
if (acquired && !acquired.isExpired()) {
|
|
3054
|
+
const violation = scopeViolation(acquired, resolved, databaseName);
|
|
3055
|
+
if (violation) throw violation;
|
|
2604
3056
|
if (resolved.hold && !acquired.hold) {
|
|
2605
3057
|
detachScopedUpgradeWrite(link, keyId, acquired);
|
|
2606
3058
|
acquired.upgradeToHold(resolved.lease);
|
|
@@ -2611,7 +3063,12 @@ export function makeTable(options) {
|
|
|
2611
3063
|
},
|
|
2612
3064
|
(error) => {
|
|
2613
3065
|
clearTimeout(followerTimer);
|
|
3066
|
+
// A follower that simply ran out of its own wait was waiting on another caller in this
|
|
3067
|
+
// process, which is the contention 423 describes. But if the LEADER failed for a reason
|
|
3068
|
+
// that is not contention, that reason is the true one — keep it and report it if the
|
|
3069
|
+
// retries below also run out, rather than ending on a 423 for a key nobody held.
|
|
2614
3070
|
if (error === followerTimedOut) throw new ClientError(`Record is locked and was not released in time`, 423);
|
|
3071
|
+
if (error instanceof LockUnavailableError) leaderFailure = error;
|
|
2615
3072
|
return retryOnRemainingBudget();
|
|
2616
3073
|
}
|
|
2617
3074
|
);
|
|
@@ -2625,46 +3082,117 @@ export function makeTable(options) {
|
|
|
2625
3082
|
resolved.lease,
|
|
2626
3083
|
resolved.hold
|
|
2627
3084
|
);
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
3085
|
+
const clusterStart = performance.now();
|
|
3086
|
+
// What a follower waits on must span the cluster round and registration, not just the native
|
|
3087
|
+
// acquire. Waking it at the native hand-off leaves it in a window where the key is held but no
|
|
3088
|
+
// handle is registered, so it retries and parks on the leader's own lock for its full timeout
|
|
3089
|
+
// — inside a transaction that cannot finish until it gives up.
|
|
3090
|
+
const acquisition = pendingPromise.then(async (handle) => {
|
|
3091
|
+
const closedWhileWaiting = () => link.open === TRANSACTION_STATE.CLOSED && !link.saveCommits;
|
|
3092
|
+
if (closedWhileWaiting()) {
|
|
3093
|
+
// The transaction was aborted while this call waited; nothing would ever release the handle.
|
|
3094
|
+
handle.release();
|
|
3095
|
+
throw new ServerError('Transaction was closed while waiting for a record lock', 500);
|
|
3096
|
+
}
|
|
3097
|
+
// Anything that fails from here must give the native key back, or it becomes a lock this
|
|
3098
|
+
// caller does not know it owns.
|
|
3099
|
+
// Re-resolved, not the snapshot taken before `acquireRecordKey`: that wait can run the
|
|
3100
|
+
// caller's whole timeout, long enough for harper-pro to register the transport on this
|
|
3101
|
+
// worker. Using the snapshot would take the native key alone and hand back a node-scoped
|
|
3102
|
+
// handle while a peer that already had the transport is granted the same key.
|
|
3103
|
+
try {
|
|
3104
|
+
if (resolved.scope !== 'node') coordinator = TableResource.lockCoordinator ?? coordinator;
|
|
3105
|
+
} catch (error) {
|
|
3106
|
+
// The getter fails closed on an unusable node identity, and that has to reach the caller
|
|
3107
|
+
// the same way it does before the wait. Swallowing it let an implicit cluster lock fall
|
|
3108
|
+
// through to node-local authority — the one outcome failing closed exists to prevent —
|
|
3109
|
+
// because `coordinator` is still whatever it was, including undefined.
|
|
3110
|
+
handle.release();
|
|
3111
|
+
throw error as Error;
|
|
3112
|
+
}
|
|
3113
|
+
if (coordinator) {
|
|
3114
|
+
try {
|
|
3115
|
+
// Not a 423 when the budget is gone, and not a skip either: the native wait can consume
|
|
3116
|
+
// the whole timeout, and `acquire` with no wait left still admits from a live delegation
|
|
3117
|
+
// or a local grant without sending anything. Only if it cannot does the caller learn the
|
|
3118
|
+
// guarantee was unavailable — which is not the same as the key being held.
|
|
3119
|
+
const remaining = Math.max(0, resolved.timeout - (performance.now() - clusterStart));
|
|
3120
|
+
const round = await coordinator.acquire(id, resolved.lease, remaining);
|
|
3121
|
+
// Resolved through the getter rather than captured, so a transport swap between
|
|
3122
|
+
// acquisition and release reaches the coordinator that now owns the delegation.
|
|
3123
|
+
if (
|
|
3124
|
+
!handle.joinClusterRound(round.tsR, resolved.lease, round.mintedMono, () =>
|
|
3125
|
+
TableResource.admittingCoordinator?.release(id, round.admissionId)
|
|
3126
|
+
)
|
|
3127
|
+
) {
|
|
3128
|
+
// The round completed inside its lease but the lease elapsed before the handle
|
|
3129
|
+
// could take it. The coordinator still holds it, and only this call knows the
|
|
3130
|
+
// hold was never handed out.
|
|
3131
|
+
// The getter, not the captured coordinator: after a transport swap the captured one no
|
|
3132
|
+
// longer owns this admission, so releasing through it would be a silent no-op.
|
|
3133
|
+
// `.then`, not `Promise.resolve(release())`: the call can throw synchronously, and that
|
|
3134
|
+
// throw would escape the catch and replace the 423 below with an internal error.
|
|
3135
|
+
Promise.resolve()
|
|
3136
|
+
.then(() => TableResource.admittingCoordinator?.release(id, round.admissionId))
|
|
3137
|
+
.catch(noop);
|
|
3138
|
+
// 503, not 423: the home granted this key to US and the lease elapsed before the handle
|
|
3139
|
+
// could take it, so nobody ever held it. The coordinator classifies the same thing the
|
|
3140
|
+
// same way — see its `timeout` denial.
|
|
3141
|
+
throw new LockUnavailableError(
|
|
3142
|
+
`A cluster record lock on ${databaseName}.${tableName} was granted after its lease had elapsed`
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
3145
|
+
// A recall must be able to fence a write this handle staged and then unlocked, so
|
|
3146
|
+
// the coordinator needs a way to revoke it — see LockCoordinator.registerAdmission.
|
|
3147
|
+
// The getter again: a swap during the acquisition moved this admission to the
|
|
3148
|
+
// successor, and registering on the predecessor would revoke a handle that is fine.
|
|
3149
|
+
TableResource.admittingCoordinator?.registerAdmission(round.admissionId, () => handle.revokeLease());
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
handle.release();
|
|
3152
|
+
throw error;
|
|
3153
|
+
}
|
|
3154
|
+
if (closedWhileWaiting()) {
|
|
2634
3155
|
handle.release();
|
|
2635
3156
|
throw new ServerError('Transaction was closed while waiting for a record lock', 500);
|
|
2636
3157
|
}
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
}
|
|
3158
|
+
}
|
|
3159
|
+
link.registerRecordLock(handle);
|
|
3160
|
+
if (link.saveCommits && (context as any)?.timestamp) handle.noteCandidateFloor((context as any).timestamp);
|
|
3161
|
+
if (link.open === TRANSACTION_STATE.OPEN && !link.saveCommits) {
|
|
3162
|
+
// Explicit transaction() (not ImmediateTransaction): pin the clock to
|
|
3163
|
+
// acquiredAt when no writes have been staged yet. When writes already
|
|
3164
|
+
// exist, leave the clock alone (ordering is best-effort; write held records
|
|
3165
|
+
// in their own transaction for the guarantee). ImmediateTransaction is
|
|
3166
|
+
// excluded (saveCommits=true) — its clock is never pinned in lock();
|
|
3167
|
+
// each save() stamps from the handle's committed version floor instead.
|
|
3168
|
+
if (link.writes.length === 0 && !link.timestamp) {
|
|
3169
|
+
link.timestamp = handle.acquiredAt;
|
|
3170
|
+
}
|
|
3171
|
+
if (!resolved.hold && link.transaction) {
|
|
3172
|
+
// Scoped lock: the read snapshot may predate the lock; drop it so the
|
|
3173
|
+
// scope reads what it locked. Hold locks use acquiredAt directly and
|
|
3174
|
+
// do not update the read snapshot.
|
|
3175
|
+
// The timestamp guard matches DatabaseTransaction's own setTimestamp calls: a
|
|
3176
|
+
// deferred update() write leaves the clock at 0, which rocksdb-js rejects.
|
|
3177
|
+
if (link.writes.length === 0 && link.readTxnsUsed <= 1) {
|
|
3178
|
+
link.releaseReadTxn();
|
|
3179
|
+
link.snapshotFree = true;
|
|
3180
|
+
} else if (link.timestamp) link.transaction.setTimestamp(link.timestamp);
|
|
2660
3181
|
}
|
|
2661
|
-
|
|
2662
|
-
|
|
3182
|
+
}
|
|
3183
|
+
// ImmediateTransaction: no clock pinning in lock(); save() stamps each write
|
|
3184
|
+
// from the committed handle floor for both scoped and hold handles.
|
|
3185
|
+
return handle;
|
|
3186
|
+
});
|
|
3187
|
+
link.registerPendingLock(primaryStore, keyId, acquisition);
|
|
3188
|
+
return acquisition.then(
|
|
3189
|
+
(handle) => {
|
|
3190
|
+
link.unregisterPendingLock(primaryStore, keyId);
|
|
2663
3191
|
return this.#reloadLocked(id, handle);
|
|
2664
3192
|
},
|
|
2665
|
-
(
|
|
3193
|
+
(error) => {
|
|
2666
3194
|
link.unregisterPendingLock(primaryStore, keyId);
|
|
2667
|
-
throw
|
|
3195
|
+
throw error;
|
|
2668
3196
|
}
|
|
2669
3197
|
);
|
|
2670
3198
|
}
|
|
@@ -2871,6 +3399,7 @@ export function makeTable(options) {
|
|
|
2871
3399
|
const context = this.getContext();
|
|
2872
3400
|
const transaction = txnForContext(context);
|
|
2873
3401
|
const replaying = transaction.isReplay === true;
|
|
3402
|
+
assertDerivedIndexAdmission(options, transaction);
|
|
2874
3403
|
checkValidId(id);
|
|
2875
3404
|
if (fullUpdate && recordUpdate == null && options?.isNotification) {
|
|
2876
3405
|
// A source/replication-applied put must carry the record; these applies skip record
|
|
@@ -2888,6 +3417,16 @@ export function makeTable(options) {
|
|
|
2888
3417
|
}
|
|
2889
3418
|
return;
|
|
2890
3419
|
}
|
|
3420
|
+
let captureChanges;
|
|
3421
|
+
if (recordUpdate === undefined) {
|
|
3422
|
+
let captured = false;
|
|
3423
|
+
captureChanges = () => {
|
|
3424
|
+
if (!captured) {
|
|
3425
|
+
captured = true;
|
|
3426
|
+
recordUpdate = this.#changes;
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
2891
3430
|
const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
|
|
2892
3431
|
const writeToSource = () => {
|
|
2893
3432
|
if (!(this.constructor as any).source || (context as any)?.source) return;
|
|
@@ -2907,12 +3446,20 @@ export function makeTable(options) {
|
|
|
2907
3446
|
}
|
|
2908
3447
|
};
|
|
2909
3448
|
|
|
3449
|
+
const receiverId = this.getId();
|
|
3450
|
+
const closesReceiver =
|
|
3451
|
+
!this.isCollection &&
|
|
3452
|
+
!isSearchTarget(receiverId) &&
|
|
3453
|
+
(id === receiverId || writeKeyId(id) === writeKeyId(receiverId));
|
|
2910
3454
|
const write: any = {
|
|
2911
3455
|
key: id,
|
|
2912
3456
|
store: primaryStore,
|
|
2913
3457
|
entry,
|
|
2914
3458
|
nodeName: (context as any)?.nodeName,
|
|
2915
3459
|
fullUpdate,
|
|
3460
|
+
chainsStagedState: true,
|
|
3461
|
+
// copy-apply rows keep their pre-read base: one read per row, healed by the post-copy replay
|
|
3462
|
+
reloadCommitBase: options?.isCopyApply !== true,
|
|
2916
3463
|
deferSave: true,
|
|
2917
3464
|
// the origin's record version on an applied write; absent for a locally-originated one
|
|
2918
3465
|
recordVersion: options?.version,
|
|
@@ -2921,8 +3468,10 @@ export function makeTable(options) {
|
|
|
2921
3468
|
// Only attach the hold handle when it covers exactly this key; off-key writes
|
|
2922
3469
|
// are ordinary and must not carry an unrelated hold's handle.
|
|
2923
3470
|
lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
|
|
3471
|
+
writeGeneration: !this.#lockWritable && closesReceiver ? this[GET_TRACKED_WRITE_GENERATION]() : undefined,
|
|
3472
|
+
captureChanges,
|
|
2924
3473
|
validate: (txnTime, committedBy = transaction) => {
|
|
2925
|
-
|
|
3474
|
+
write.captureChanges?.();
|
|
2926
3475
|
if (fullUpdate || (recordUpdate && hasChanges(this.#changes === recordUpdate ? this : recordUpdate))) {
|
|
2927
3476
|
if (!(context as any)?.source) {
|
|
2928
3477
|
committedBy.checkOverloaded();
|
|
@@ -2973,10 +3522,13 @@ export function makeTable(options) {
|
|
|
2973
3522
|
: txnTime;
|
|
2974
3523
|
}
|
|
2975
3524
|
if (createdTimeProperty) {
|
|
2976
|
-
|
|
3525
|
+
// the reloaded commit base, not the pre-read one: a full PUT racing a create
|
|
3526
|
+
// would otherwise stamp a fresh created time over the real one
|
|
3527
|
+
const base = write.entry;
|
|
3528
|
+
if (base?.value) {
|
|
2977
3529
|
if (fullUpdate || recordUpdate[createdTimeProperty.name]) {
|
|
2978
3530
|
// make sure to retain original created time
|
|
2979
|
-
recordUpdate[createdTimeProperty.name] =
|
|
3531
|
+
recordUpdate[createdTimeProperty.name] = base.value[createdTimeProperty.name];
|
|
2980
3532
|
}
|
|
2981
3533
|
} else {
|
|
2982
3534
|
// new entry, set created time
|
|
@@ -3091,6 +3643,8 @@ export function makeTable(options) {
|
|
|
3091
3643
|
// existing timestamp, which means that we received updates out of order, and must resequence the application
|
|
3092
3644
|
// of the updates to the record to ensure consistency across the cluster
|
|
3093
3645
|
// TODO: can the previous version be older, but even more previous version be newer?
|
|
3646
|
+
let belowAuditFloor = false;
|
|
3647
|
+
let dedupVersionCouldBeRetained: (version: number) => boolean;
|
|
3094
3648
|
if (audit) {
|
|
3095
3649
|
// A re-delivered out-of-order write (full-copy audit-replay re-delivers writes) must not have
|
|
3096
3650
|
// its commutative ops re-folded. additionalAuditRefs is the record's own list of folded
|
|
@@ -3136,7 +3690,7 @@ export function makeTable(options) {
|
|
|
3136
3690
|
// Resolve the oldest retained entry once, for the same log the dedup reads.
|
|
3137
3691
|
let oldestRetainedAuditTime: number | undefined;
|
|
3138
3692
|
let oldestRetainedAuditTimeResolved = false;
|
|
3139
|
-
|
|
3693
|
+
dedupVersionCouldBeRetained = (version: number): boolean => {
|
|
3140
3694
|
if (!isRocksDB) return true; // LMDB keeps its exact, unbounded lookup (keyed by local audit time)
|
|
3141
3695
|
if (!oldestRetainedAuditTimeResolved) {
|
|
3142
3696
|
oldestRetainedAuditTimeResolved = true;
|
|
@@ -3183,6 +3737,19 @@ export function makeTable(options) {
|
|
|
3183
3737
|
return; // duplicate already applied; avoid the resequencing walk
|
|
3184
3738
|
}
|
|
3185
3739
|
}
|
|
3740
|
+
// The walk terminates at this write only by reaching an audit entry whose log key is at or below
|
|
3741
|
+
// txnTime (the loop condition below), so below the floor it cannot: it runs the whole retained
|
|
3742
|
+
// chain to an outcome the floor already determines (harper#2642). txnTime is the coordinate
|
|
3743
|
+
// because it is the one that loop compares; txnLogKey addresses this write's own entry, a
|
|
3744
|
+
// different question that dedupVersionCouldBeRetained already answers. An unknown floor is
|
|
3745
|
+
// Infinity, which fails closed for a cursor check but has to fail OPEN here — walk rather than
|
|
3746
|
+
// discard. RocksDB only: LMDB keeps its exact, unbounded reconciliation.
|
|
3747
|
+
if (isRocksDB && precedesExisting < 0) {
|
|
3748
|
+
const auditFloor = getAuditFloor(auditStore);
|
|
3749
|
+
belowAuditFloor = Number.isFinite(auditFloor) && txnTime < auditFloor;
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
if (audit && !belowAuditFloor) {
|
|
3186
3753
|
// incremental CRDT updates are only available with audit logging on
|
|
3187
3754
|
const initialAuditHead = isRocksDB
|
|
3188
3755
|
? resolveAuditHead(id, existingEntry.version, existingEntry.nodeId, existingEntry.additionalAuditRefs)
|
|
@@ -3200,7 +3767,12 @@ export function makeTable(options) {
|
|
|
3200
3767
|
new Date(localTime)
|
|
3201
3768
|
);
|
|
3202
3769
|
|
|
3203
|
-
|
|
3770
|
+
// Normalized here and not at the lookup, because the two sources of an undefined nodeId do not
|
|
3771
|
+
// mean the same thing: a record's own nodeId is resolved by the same expression as the id its
|
|
3772
|
+
// audit entry is logged under, which applies `?? 0` (RecordEncoder), so absent means log 0;
|
|
3773
|
+
// `previousNodeId` is never encoded, so absent there means the log is unknown and only the
|
|
3774
|
+
// aggregate lookup can resolve a cross-origin predecessor.
|
|
3775
|
+
let nodeId = initialAuditHead.nodeId ?? 0;
|
|
3204
3776
|
const succeedingUpdates = []; // record the "future" updates, as we need to apply the updates in reverse order
|
|
3205
3777
|
const auditRefsToVisit: Array<{ localTime: number; nodeId: number }> = existingEntry.additionalAuditRefs
|
|
3206
3778
|
? existingEntry.additionalAuditRefs.map((ref) => ({ localTime: ref.version, nodeId: ref.nodeId }))
|
|
@@ -3464,6 +4036,31 @@ export function makeTable(options) {
|
|
|
3464
4036
|
write.skipped = true;
|
|
3465
4037
|
return;
|
|
3466
4038
|
}
|
|
4039
|
+
} else if (belowAuditFloor) {
|
|
4040
|
+
// The walk cannot reach this write, so it may contribute only what is order-independent: its
|
|
4041
|
+
// commutative ops. Whether a plain field survives depends on what newer writes did to that key,
|
|
4042
|
+
// which is what the purged history no longer answers, so applying one would resurrect state a
|
|
4043
|
+
// newer put may have erased. Everything else loses to the strictly newer head: a full update, a
|
|
4044
|
+
// head carrying no record (a delete or a residency-omitted record, which an op must not
|
|
4045
|
+
// resurrect), and an op-less patch. Bare return, no writeCommit, so no audit record references
|
|
4046
|
+
// the losing update's pre-saved blobs.
|
|
4047
|
+
incrementalUpdateToApply = fullUpdate || existingRecord == null ? null : commutativeOpsOf(recordUpdate);
|
|
4048
|
+
if (!incrementalUpdateToApply) {
|
|
4049
|
+
write.skipped = true;
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
// The surviving head's addressable log-key pointer lives in these, wherever the record and log
|
|
4053
|
+
// clocks differ.
|
|
4054
|
+
if (existingEntry.additionalAuditRefs) {
|
|
4055
|
+
for (const ref of existingEntry.additionalAuditRefs) {
|
|
4056
|
+
additionalAuditRefs.push(ref);
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
// Once the walk no longer runs, this ref is what the read-your-writes check above matches a
|
|
4060
|
+
// re-delivery of these ops on. Best-effort, like every other guard here: the encoder bounds
|
|
4061
|
+
// the persisted list, so an identity can age out of it (harper#1148's full-copy convergence
|
|
4062
|
+
// is the backstop).
|
|
4063
|
+
additionalAuditRefs.push({ version: txnLogKey, nodeId: options?.nodeId });
|
|
3467
4064
|
} else if (fullUpdate) {
|
|
3468
4065
|
// if no audit, we can't accurately do incremental updates, so we just assume the last update
|
|
3469
4066
|
// was the same type. Assuming a full update this record update loses and there are no changes —
|
|
@@ -3495,8 +4092,8 @@ export function makeTable(options) {
|
|
|
3495
4092
|
if (recordToStore && recordToStore.getRecord)
|
|
3496
4093
|
throw new Error('Can not assign a record to a record, check for circular references');
|
|
3497
4094
|
if (residencyId == undefined) {
|
|
3498
|
-
if (
|
|
3499
|
-
(context as any).previousResidency = TableResource.getResidencyRecord(
|
|
4095
|
+
if (existingEntry?.residencyId)
|
|
4096
|
+
(context as any).previousResidency = TableResource.getResidencyRecord(existingEntry.residencyId);
|
|
3500
4097
|
const residency = residencyFromFunction(TableResource.getResidency(recordToStore, context));
|
|
3501
4098
|
if (residency) {
|
|
3502
4099
|
if (!residency.includes(server.hostname)) {
|
|
@@ -3736,6 +4333,7 @@ export function makeTable(options) {
|
|
|
3736
4333
|
this.#assertLiveHandle(id);
|
|
3737
4334
|
const context = this.getContext();
|
|
3738
4335
|
const transaction = txnForContext(context);
|
|
4336
|
+
assertDerivedIndexAdmission(options, transaction);
|
|
3739
4337
|
checkValidId(id);
|
|
3740
4338
|
const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
|
|
3741
4339
|
|
|
@@ -3744,6 +4342,7 @@ export function makeTable(options) {
|
|
|
3744
4342
|
store: primaryStore,
|
|
3745
4343
|
entry,
|
|
3746
4344
|
chainsStagedState: true,
|
|
4345
|
+
reloadCommitBase: true,
|
|
3747
4346
|
nodeName: (context as any)?.nodeName,
|
|
3748
4347
|
recordVersion: options?.version,
|
|
3749
4348
|
lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
|
|
@@ -3967,6 +4566,7 @@ export function makeTable(options) {
|
|
|
3967
4566
|
// objects. Entries are small and shallow; the clone is cheap next to the query.
|
|
3968
4567
|
conditions = cloneConditions(conditions);
|
|
3969
4568
|
let orderAlignedCondition;
|
|
4569
|
+
let syntheticOrderCondition;
|
|
3970
4570
|
const filtered = {};
|
|
3971
4571
|
|
|
3972
4572
|
function prepareConditions(conditions: any[], operator: string) {
|
|
@@ -4105,7 +4705,7 @@ export function makeTable(options) {
|
|
|
4105
4705
|
// if it is indexed, we add a pseudo-condition to align with the natural sort order of the index.
|
|
4106
4706
|
// the primary key has no secondary index, but the primary store is itself keyed in
|
|
4107
4707
|
// primary-key order, so scanning it is already aligned with the sort
|
|
4108
|
-
orderAlignedCondition = { ...sort, comparator: 'sort' };
|
|
4708
|
+
orderAlignedCondition = syntheticOrderCondition = { ...sort, comparator: 'sort' };
|
|
4109
4709
|
conditions.push(orderAlignedCondition);
|
|
4110
4710
|
} else if (conditions.length === 0 && !target.allowFullScan)
|
|
4111
4711
|
throw handleHDBError(
|
|
@@ -4116,7 +4716,13 @@ export function makeTable(options) {
|
|
|
4116
4716
|
404
|
|
4117
4717
|
);
|
|
4118
4718
|
}
|
|
4119
|
-
if (orderAlignedCondition)
|
|
4719
|
+
if (orderAlignedCondition) {
|
|
4720
|
+
orderAlignedCondition.descending = Boolean(sort.descending);
|
|
4721
|
+
if (orderAlignedCondition.maxIndexLagMilliseconds === undefined)
|
|
4722
|
+
orderAlignedCondition.maxIndexLagMilliseconds = sort.maxIndexLagMilliseconds;
|
|
4723
|
+
if (orderAlignedCondition.waitForIndexMilliseconds === undefined)
|
|
4724
|
+
orderAlignedCondition.waitForIndexMilliseconds = sort.waitForIndexMilliseconds;
|
|
4725
|
+
}
|
|
4120
4726
|
}
|
|
4121
4727
|
}
|
|
4122
4728
|
conditions = orderConditions(conditions, operator);
|
|
@@ -4135,8 +4741,10 @@ export function makeTable(options) {
|
|
|
4135
4741
|
};
|
|
4136
4742
|
}
|
|
4137
4743
|
} else {
|
|
4138
|
-
// if we had to add an aligned condition that isn't first, we remove it and do ordering later
|
|
4139
|
-
|
|
4744
|
+
// if we had to add an aligned condition that isn't first, we remove it and do ordering later —
|
|
4745
|
+
// only the one we added; a caller's own condition on the sort attribute is still a filter
|
|
4746
|
+
const syntheticIndex = syntheticOrderCondition ? conditions.indexOf(syntheticOrderCondition) : -1;
|
|
4747
|
+
if (syntheticIndex >= 0) conditions.splice(syntheticIndex, 1);
|
|
4140
4748
|
postOrdering = sort;
|
|
4141
4749
|
}
|
|
4142
4750
|
}
|
|
@@ -4182,170 +4790,175 @@ export function makeTable(options) {
|
|
|
4182
4790
|
boundRowFilter || typeof target.vectorFilter === 'function'
|
|
4183
4791
|
? { rowFilter: boundRowFilter, vectorFilter: target.vectorFilter }
|
|
4184
4792
|
: undefined;
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
conds
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4793
|
+
try {
|
|
4794
|
+
const entries = executeConditions(
|
|
4795
|
+
conditions,
|
|
4796
|
+
operator,
|
|
4797
|
+
TableResource,
|
|
4798
|
+
readTxn,
|
|
4799
|
+
target,
|
|
4800
|
+
context,
|
|
4801
|
+
(results: any[], filters: Function[]) => transformToEntries(results, select, context, readTxn, filters),
|
|
4802
|
+
filtered,
|
|
4803
|
+
recordAccess
|
|
4804
|
+
);
|
|
4805
|
+
const ensure_loaded = (target as any).ensureLoaded !== false;
|
|
4806
|
+
// The guards inside executeConditions evaluate the
|
|
4807
|
+
// LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
|
|
4808
|
+
// expired/invalidated row from source and return a DIFFERENT record. The explicit row filter
|
|
4809
|
+
// must hold on the record actually returned, so it is re-checked
|
|
4810
|
+
// there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
|
|
4811
|
+
// traversal). vectorFilter and condition filters intentionally keep the local-record
|
|
4812
|
+
// semantics all query filters have on caching tables.
|
|
4813
|
+
//
|
|
4814
|
+
// A row that is past its TTL but not yet swept by the background eviction
|
|
4815
|
+
// scan is still physically present. A write that is about to overwrite it
|
|
4816
|
+
// anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
|
|
4817
|
+
// it as a match — the same leniency a direct by-id put/patch already gets,
|
|
4818
|
+
// since those never run the ensureLoaded-gated freshness check this transform
|
|
4819
|
+
// otherwise applies unconditionally to every read.
|
|
4820
|
+
const includeExpired = (target as any).includeExpired === true;
|
|
4821
|
+
const transformToRecord = TableResource.transformEntryForSelect(
|
|
4822
|
+
select,
|
|
4823
|
+
context,
|
|
4824
|
+
readTxn,
|
|
4825
|
+
filtered,
|
|
4826
|
+
ensure_loaded,
|
|
4827
|
+
true,
|
|
4828
|
+
boundRowFilter,
|
|
4829
|
+
includeExpired,
|
|
4830
|
+
postOrdering
|
|
4831
|
+
);
|
|
4832
|
+
let results = TableResource.transformToOrderedSelect(
|
|
4833
|
+
entries,
|
|
4834
|
+
select,
|
|
4835
|
+
postOrdering,
|
|
4836
|
+
context,
|
|
4837
|
+
readTxn,
|
|
4838
|
+
transformToRecord
|
|
4839
|
+
);
|
|
4840
|
+
const offset = target.offset || 0;
|
|
4841
|
+
const end = target.limit !== undefined ? offset + (target.limit as number) : undefined;
|
|
4842
|
+
// `Prefer: count=` (REST pagination): materialize the requested page and attach a total record
|
|
4843
|
+
// count so the HTTP layer can emit a Content-Range. `exact` drains the full matched set once,
|
|
4844
|
+
// windowing the page in the same pass; `estimated` returns just the page plus a cheap planner/
|
|
4845
|
+
// table estimate. Opt-in only — the default streaming path below is untouched.
|
|
4846
|
+
//
|
|
4847
|
+
// Requires a bounded page AND window. Counting is a pagination feature; both the limit and the
|
|
4848
|
+
// offset must be finite, non-negative integers, the limit no larger than MAX_COUNT_PAGE, and the
|
|
4849
|
+
// window (offset + limit) no larger than MAX_EXACT_COUNT_SCAN. Anything else — a missing/
|
|
4850
|
+
// oversized/non-finite/negative limit or offset (a bare collection GET, limit(Infinity),
|
|
4851
|
+
// limit(foo), limit(-5,10)) or a deep-page window past the scan budget — falls through to the
|
|
4852
|
+
// normal streaming path with no count. This bounds the offset too: without it a huge offset would
|
|
4853
|
+
// postpone the exact guardrail (which only engages past the page) until that offset was scanned.
|
|
4854
|
+
const pageLimit = target.limit as number;
|
|
4855
|
+
if (
|
|
4856
|
+
target.count &&
|
|
4857
|
+
Number.isInteger(pageLimit) &&
|
|
4858
|
+
pageLimit >= 0 &&
|
|
4859
|
+
pageLimit <= MAX_COUNT_PAGE &&
|
|
4860
|
+
Number.isInteger(offset) &&
|
|
4861
|
+
offset >= 0 &&
|
|
4862
|
+
offset + pageLimit <= MAX_EXACT_COUNT_SCAN
|
|
4863
|
+
) {
|
|
4864
|
+
const wantExact = target.count === 'exact';
|
|
4865
|
+
const pageEnd = offset + pageLimit;
|
|
4866
|
+
const countStart = performance.now();
|
|
4867
|
+
// A custom-index (vector/HNSW) traversal returns a bounded, approximate candidate set whose size is
|
|
4868
|
+
// chosen from `minResults` (offset + limit), so `scanned` over it tracks the requested page size, not
|
|
4869
|
+
// the true match count — the same query at limit(5) vs limit(200) would otherwise advertise two
|
|
4870
|
+
// different `count=exact` totals. Any query whose execution touches a custom index is affected: a
|
|
4871
|
+
// custom-index sort (its aligned pseudo-condition lands in `conditions`), a custom-index threshold
|
|
4872
|
+
// filter (an HNSW `lt`/`le` is the same minResults-widened traversal as a sort), or an opaque vector
|
|
4873
|
+
// filter. Report the total as unavailable for those rather than advertising it as count=exact
|
|
4874
|
+
// (mirroring how the estimated branch below bails to null for an opaque row/vector filter). A vector
|
|
4875
|
+
// sort applied as in-memory post-ordering leaves no custom-index condition here and stays exact.
|
|
4876
|
+
const touchesCustomIndex = (conds: any[]): boolean =>
|
|
4877
|
+
conds.some((c: any) => {
|
|
4878
|
+
if (!c) return false;
|
|
4879
|
+
if (c.conditions) return touchesCustomIndex(c.conditions);
|
|
4880
|
+
const attr = Array.isArray(c.attribute) ? c.attribute[0] : (c.attribute ?? c[0]);
|
|
4881
|
+
return typeof attr === 'string' && Boolean(indices[attr]?.customIndex);
|
|
4882
|
+
});
|
|
4883
|
+
const approximateResultSet = typeof target.vectorFilter === 'function' || touchesCustomIndex(conditions);
|
|
4884
|
+
return (async () => {
|
|
4885
|
+
const page: any = [];
|
|
4886
|
+
let scanned = 0;
|
|
4887
|
+
let exact = true;
|
|
4888
|
+
try {
|
|
4889
|
+
for await (const record of results) {
|
|
4890
|
+
if (scanned >= offset && scanned < pageEnd) page.push(record);
|
|
4891
|
+
scanned++;
|
|
4892
|
+
// A store whose async iterator settles synchronously (the common indexed-scan case) would
|
|
4893
|
+
// otherwise let this drain spin as one uninterrupted microtask run, blocking the event loop
|
|
4894
|
+
// for the whole count. Yield to the macrotask queue periodically so concurrent requests and
|
|
4895
|
+
// I/O still make progress during a large exact scan.
|
|
4896
|
+
if ((scanned & (COUNT_YIELD_INTERVAL - 1)) === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
4897
|
+
// The page window [offset, pageEnd) is always collected in full first — the guardrail
|
|
4898
|
+
// only ever abandons the running TOTAL, never truncates the page body.
|
|
4899
|
+
if (scanned >= pageEnd) {
|
|
4900
|
+
// `estimated` needs nothing past the page; an approximate (vector) exact total is going to
|
|
4901
|
+
// be reported unavailable anyway, so don't drain its tail for a number we won't publish.
|
|
4902
|
+
if (!wantExact || approximateResultSet) break;
|
|
4903
|
+
// `exact` keeps counting the tail, bounded by a row cap AND a time budget so a
|
|
4904
|
+
// large match set can't turn a bounded page fetch into an unbounded scan.
|
|
4905
|
+
if (scanned > MAX_EXACT_COUNT_SCAN || performance.now() - countStart > MAX_EXACT_COUNT_MS) {
|
|
4906
|
+
exact = false;
|
|
4907
|
+
break;
|
|
4908
|
+
}
|
|
4299
4909
|
}
|
|
4300
4910
|
}
|
|
4911
|
+
} finally {
|
|
4912
|
+
// We own the iteration here (no results.onDone consumer), so release the read
|
|
4913
|
+
// transaction unconditionally — including when the drain throws — or the snapshot leaks.
|
|
4914
|
+
txn.doneReadTxn();
|
|
4301
4915
|
}
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
total
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
results
|
|
4916
|
+
let total: number | null;
|
|
4917
|
+
if (wantExact) {
|
|
4918
|
+
// `scanned` is only an authoritative total when the iteration was exhaustive and deterministic;
|
|
4919
|
+
// an approximate (vector/HNSW) result set is neither, so report the total as unavailable.
|
|
4920
|
+
total = exact && !approximateResultSet ? scanned : null;
|
|
4921
|
+
} else if (boundRowFilter || typeof target.vectorFilter === 'function') {
|
|
4922
|
+
// An opaque row/vector filter shapes the result but isn't reflected in the index/condition
|
|
4923
|
+
// estimate; guessing would both mislead and disclose cardinality the filter hides.
|
|
4924
|
+
total = null;
|
|
4925
|
+
} else if (!hasUserConditions) {
|
|
4926
|
+
total = estimatedEntryCount(primaryStore);
|
|
4927
|
+
} else {
|
|
4928
|
+
// Estimate from the real conditions only — drop the planner's synthetic `sort`
|
|
4929
|
+
// pseudo-condition, which otherwise contributes a bogus (entryCount/2) cardinality.
|
|
4930
|
+
const est = estimateCondition(TableResource)({
|
|
4931
|
+
conditions: conditions.filter((c: any) => c.comparator !== 'sort'),
|
|
4932
|
+
operator: operator ? String(operator).toLowerCase() : 'and',
|
|
4933
|
+
});
|
|
4934
|
+
total = isFinite(est) ? Math.round(est) : null;
|
|
4935
|
+
}
|
|
4936
|
+
// For an estimate, never report a total below the last row actually returned — keeps the
|
|
4937
|
+
// Content-Range valid (start-end/total) when an estimate undershoots a non-empty page.
|
|
4938
|
+
// Exact totals are authoritative (and an empty page past the end must not be clamped up).
|
|
4939
|
+
if (!wantExact && total != null && page.length > 0 && total < offset + page.length) {
|
|
4940
|
+
total = offset + page.length;
|
|
4941
|
+
}
|
|
4942
|
+
page.recordCount = total;
|
|
4943
|
+
page.recordCountExact = wantExact && exact && !approximateResultSet;
|
|
4944
|
+
page.selectApplied = true;
|
|
4945
|
+
page.getColumns = getColumns;
|
|
4946
|
+
return page;
|
|
4947
|
+
})() as any;
|
|
4948
|
+
}
|
|
4949
|
+
// apply any offset/limit after all the sorting and filtering
|
|
4950
|
+
if (target.offset || target.limit !== undefined) results = results.slice(offset, end);
|
|
4951
|
+
results.onDone = () => {
|
|
4952
|
+
results.onDone = null; // ensure that it isn't called twice
|
|
4953
|
+
txn.doneReadTxn();
|
|
4954
|
+
};
|
|
4955
|
+
results.selectApplied = true;
|
|
4956
|
+
results.getColumns = getColumns;
|
|
4957
|
+
return results;
|
|
4958
|
+
} catch (error) {
|
|
4344
4959
|
txn.doneReadTxn();
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
results.getColumns = getColumns;
|
|
4348
|
-
return results;
|
|
4960
|
+
throw error;
|
|
4961
|
+
}
|
|
4349
4962
|
}
|
|
4350
4963
|
/**
|
|
4351
4964
|
* This is responsible for ordering and select()ing the attributes/properties from returned entries
|
|
@@ -4368,10 +4981,17 @@ export function makeTable(options) {
|
|
|
4368
4981
|
if (sort) {
|
|
4369
4982
|
// there might be some situations where we don't need to transform to entries for sorting, not sure
|
|
4370
4983
|
entries = transformToEntries(entries, select, context, readTxn, null);
|
|
4371
|
-
|
|
4984
|
+
// Sort keys are resolved as entries are collected, so comparison never dereferences a record: a
|
|
4985
|
+
// cached entry holds its record only weakly, and a re-read per comparison is what this avoids.
|
|
4986
|
+
const clauses: Sort[] = [];
|
|
4987
|
+
for (let order = sort; order; order = order.next) clauses.push(order);
|
|
4988
|
+
const clauseCount = clauses.length;
|
|
4372
4989
|
// if we are doing post-ordering, we need to get records first, then sort them
|
|
4373
4990
|
results.iterate = function (options: { async: boolean }) {
|
|
4374
|
-
let
|
|
4991
|
+
let ordered: any[];
|
|
4992
|
+
let orderedKeys: any[][];
|
|
4993
|
+
let sortedPositions: number[];
|
|
4994
|
+
let sortedIndex: number;
|
|
4375
4995
|
const dbIterator =
|
|
4376
4996
|
options?.async && entries[Symbol.asyncIterator]
|
|
4377
4997
|
? entries[Symbol.asyncIterator]()
|
|
@@ -4381,25 +5001,33 @@ export function makeTable(options) {
|
|
|
4381
5001
|
let enqueuedEntryForNextGroup: any;
|
|
4382
5002
|
let lastGroupingValue: any;
|
|
4383
5003
|
let firstEntry = true;
|
|
4384
|
-
function
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
5004
|
+
function collect(entry) {
|
|
5005
|
+
ordered.push(entry);
|
|
5006
|
+
for (let i = 0; i < clauseCount; i++) {
|
|
5007
|
+
const clause = clauses[i];
|
|
5008
|
+
orderedKeys[i].push(convertToComparableKeys(getAttributeValue(entry, clause.attribute, context, clause)));
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
function comparePositions(positionA: number, positionB: number): number {
|
|
5012
|
+
for (let i = 0; i < clauseCount; i++) {
|
|
5013
|
+
const keys = orderedKeys[i];
|
|
5014
|
+
const diff = clauses[i].descending
|
|
5015
|
+
? compareKeys(keys[positionB], keys[positionA])
|
|
5016
|
+
: compareKeys(keys[positionA], keys[positionB]);
|
|
5017
|
+
if (diff !== 0) return diff;
|
|
5018
|
+
}
|
|
5019
|
+
return 0;
|
|
5020
|
+
}
|
|
5021
|
+
function nextSorted(): IteratorResult<any> {
|
|
5022
|
+
if (sortedIndex < sortedPositions.length)
|
|
5023
|
+
return { done: false, value: ordered[sortedPositions[sortedIndex++]] };
|
|
5024
|
+
return { done: true, value: undefined };
|
|
4396
5025
|
}
|
|
4397
|
-
const comparator = createComparator(sort);
|
|
4398
5026
|
return {
|
|
4399
5027
|
async next() {
|
|
4400
5028
|
let iteration: IteratorResult<any>;
|
|
4401
|
-
if (
|
|
4402
|
-
iteration =
|
|
5029
|
+
if (sortedPositions) {
|
|
5030
|
+
iteration = nextSorted();
|
|
4403
5031
|
if (iteration.done) {
|
|
4404
5032
|
if (dbDone) {
|
|
4405
5033
|
if (results.onDone) results.onDone();
|
|
@@ -4411,7 +5039,9 @@ export function makeTable(options) {
|
|
|
4411
5039
|
};
|
|
4412
5040
|
}
|
|
4413
5041
|
ordered = [];
|
|
4414
|
-
|
|
5042
|
+
orderedKeys = [];
|
|
5043
|
+
for (let i = 0; i < clauseCount; i++) orderedKeys.push([]);
|
|
5044
|
+
if (enqueuedEntryForNextGroup) collect(enqueuedEntryForNextGroup);
|
|
4415
5045
|
// need to load all the entries into ordered
|
|
4416
5046
|
do {
|
|
4417
5047
|
iteration = await dbIterator.next();
|
|
@@ -4442,17 +5072,17 @@ export function makeTable(options) {
|
|
|
4442
5072
|
break;
|
|
4443
5073
|
}
|
|
4444
5074
|
}
|
|
4445
|
-
|
|
4446
|
-
// before the sorting is completed
|
|
4447
|
-
ordered.push(entry);
|
|
5075
|
+
collect(entry);
|
|
4448
5076
|
}
|
|
4449
5077
|
} while (true);
|
|
4450
5078
|
if ((sort as any).isGrouped) {
|
|
4451
5079
|
// TODO: Return grouped results
|
|
4452
5080
|
}
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
5081
|
+
sortedPositions = [];
|
|
5082
|
+
for (let i = 0; i < ordered.length; i++) sortedPositions.push(i);
|
|
5083
|
+
sortedPositions.sort(comparePositions);
|
|
5084
|
+
sortedIndex = 0;
|
|
5085
|
+
iteration = nextSorted();
|
|
4456
5086
|
if (!iteration.done)
|
|
4457
5087
|
return {
|
|
4458
5088
|
value: await transformToRecord.call(this, iteration.value),
|
|
@@ -4859,6 +5489,8 @@ export function makeTable(options) {
|
|
|
4859
5489
|
if (dropDuringReplay) return;
|
|
4860
5490
|
try {
|
|
4861
5491
|
let type = auditRecord.type;
|
|
5492
|
+
// Ahead of the rawEvents branch, which forwards every type verbatim.
|
|
5493
|
+
if (isLockControlType(type)) return;
|
|
4862
5494
|
let value;
|
|
4863
5495
|
if (type === 'message' || request.rawEvents) {
|
|
4864
5496
|
// we only send the full message, this are individual messages that can be sent out of order
|
|
@@ -4949,7 +5581,8 @@ export function makeTable(options) {
|
|
|
4949
5581
|
await rest();
|
|
4950
5582
|
if (!isActive()) return;
|
|
4951
5583
|
}
|
|
4952
|
-
if (auditRecord.tableId !== tableId) continue;
|
|
5584
|
+
if (auditRecord.tableId !== tableId || auditRecord.type === 'evict') continue;
|
|
5585
|
+
if (isLockControlType(auditRecord.type)) continue;
|
|
4953
5586
|
const id = auditRecord.recordId;
|
|
4954
5587
|
if (thisId == null || isDescendantId(thisId, id)) {
|
|
4955
5588
|
const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.txnLogKey);
|
|
@@ -4986,7 +5619,8 @@ export function makeTable(options) {
|
|
|
4986
5619
|
if (!isActive()) return;
|
|
4987
5620
|
}
|
|
4988
5621
|
try {
|
|
4989
|
-
if (auditRecord.tableId !== tableId) continue;
|
|
5622
|
+
if (auditRecord.tableId !== tableId || auditRecord.type === 'evict') continue;
|
|
5623
|
+
if (isLockControlType(auditRecord.type)) continue;
|
|
4990
5624
|
const id = auditRecord.recordId;
|
|
4991
5625
|
if (thisId == null || isDescendantId(thisId, id)) {
|
|
4992
5626
|
// Bound entries INSPECTED for THIS scope, independent of `count` (entries
|
|
@@ -5411,6 +6045,115 @@ export function makeTable(options) {
|
|
|
5411
6045
|
});
|
|
5412
6046
|
});
|
|
5413
6047
|
}
|
|
6048
|
+
/**
|
|
6049
|
+
* Write one cluster record-lock control entry (harper#483 Phase 1). Not local-only: replicating
|
|
6050
|
+
* it IS the send.
|
|
6051
|
+
*
|
|
6052
|
+
* `recordId` must stay null. An entry carrying the locked key would share
|
|
6053
|
+
* `(version, tableId, recordId, nodeId)` with the holder's own first write, which is stamped at
|
|
6054
|
+
* exactly `ts_R`, and `RocksTransactionLogStore.getSync` answers with the FIRST entry at a
|
|
6055
|
+
* timestamp and key — so `_writeUpdate`'s keyed dedup would find this one and drop that write.
|
|
6056
|
+
* The payload goes in as bytes rather than through `recordUpdater`, which would run it through
|
|
6057
|
+
* schema projection and the table's shared structure dictionary.
|
|
6058
|
+
*/
|
|
6059
|
+
static writeLockControlEntry(entry: LockControlEntry): Promise<number | undefined> {
|
|
6060
|
+
const encodedRecord = encodeLockControlPayload(entry);
|
|
6061
|
+
const nodeId = getThisNodeId(auditStore) ?? 0;
|
|
6062
|
+
let position: number;
|
|
6063
|
+
// No entry pins its clock, the request included. `ts_R` is minted before the write, so pinning
|
|
6064
|
+
// to it can land the entry behind a peer's replication cursor if any write to this table
|
|
6065
|
+
// commits in between — the same hazard that rules it out for grants and releases, which are
|
|
6066
|
+
// written later still. The protocol reads `ts_R` from the payload, so the entry's own log key
|
|
6067
|
+
// never has to equal it.
|
|
6068
|
+
const context = {};
|
|
6069
|
+
return Promise.resolve(
|
|
6070
|
+
transaction(context as any, (txn: any) => {
|
|
6071
|
+
const tableTxn = txnForContext({ transaction: txn } as any);
|
|
6072
|
+
tableTxn.addWrite({
|
|
6073
|
+
key: null,
|
|
6074
|
+
store: primaryStore,
|
|
6075
|
+
skipReplicationConfirmation: true,
|
|
6076
|
+
commit: (txnTime: number, _existingEntry: any, _retry: any, nativeTransaction: any) => {
|
|
6077
|
+
position = txnTime;
|
|
6078
|
+
return auditStore[isRocksDB ? 'putSync' : 'put'](
|
|
6079
|
+
null,
|
|
6080
|
+
{
|
|
6081
|
+
version: txnTime,
|
|
6082
|
+
tableId,
|
|
6083
|
+
recordId: null,
|
|
6084
|
+
nodeId,
|
|
6085
|
+
type: entry.type,
|
|
6086
|
+
encodedRecord,
|
|
6087
|
+
extendedType: 0,
|
|
6088
|
+
// Zero, not the table's count: these bytes were packed by the private control `Packr`
|
|
6089
|
+
// and carry none of the table's structures. `RocksTransactionLogStore` raises the
|
|
6090
|
+
// per-(log, table) structure watermark from this field and flags the entry that does
|
|
6091
|
+
// it, so claiming the table's version would let a release take `HAS_STRUCTURE_UPDATE`
|
|
6092
|
+
// and leave the next real write at that version unflagged — a receiver that learns
|
|
6093
|
+
// structures only from flagged entries then decodes later records against a stale set
|
|
6094
|
+
// (harper#1348's class). A payload with no table structures cannot advance them.
|
|
6095
|
+
structureVersion: 0,
|
|
6096
|
+
},
|
|
6097
|
+
{ instructedWrite: true, transaction: nativeTransaction, nodeId, viaNodeId: nodeId }
|
|
6098
|
+
);
|
|
6099
|
+
},
|
|
6100
|
+
});
|
|
6101
|
+
})
|
|
6102
|
+
).then(() => position);
|
|
6103
|
+
}
|
|
6104
|
+
/**
|
|
6105
|
+
* The coordinator that holds this node's admissions, transport or not. Releasing and registering
|
|
6106
|
+
* go here rather than through `lockCoordinator`, which answers undefined while a transport is
|
|
6107
|
+
* momentarily unregistered — and a release dropped on that answer leaves the key's home holding
|
|
6108
|
+
* its grant until the delegation's own deadline.
|
|
6109
|
+
*/
|
|
6110
|
+
static get admittingCoordinator(): LockCoordinator | undefined {
|
|
6111
|
+
return lockCoordinator;
|
|
6112
|
+
}
|
|
6113
|
+
|
|
6114
|
+
/**
|
|
6115
|
+
* This table's cluster lock coordinator, created on first use and only while a transport is
|
|
6116
|
+
* registered for the database. Nothing is allocated on the Phase 0 path.
|
|
6117
|
+
*/
|
|
6118
|
+
static get lockCoordinator(): LockCoordinator | undefined {
|
|
6119
|
+
const transport = getClusterLockTransport(databaseName);
|
|
6120
|
+
if (!transport) {
|
|
6121
|
+
// Deliberately NOT closed. harper-pro unregisters without a standalone claim during a
|
|
6122
|
+
// reconnect, and closing here would drop this node's record of the delegations it has
|
|
6123
|
+
// issued as a home — so the next registration would start empty and could grant a key
|
|
6124
|
+
// whose delegate is still admitting. The coordinator keeps ticking, its grants expire on
|
|
6125
|
+
// their own deadlines, and `isClusterLockRequired` is what fails an acquire closed in the
|
|
6126
|
+
// meantime. A genuine standalone claim clears the requirement and the coordinator with it.
|
|
6127
|
+
if (!isClusterLockRequired(databaseName)) {
|
|
6128
|
+
lockCoordinator?.close();
|
|
6129
|
+
lockCoordinator = undefined;
|
|
6130
|
+
}
|
|
6131
|
+
return undefined;
|
|
6132
|
+
}
|
|
6133
|
+
if (lockCoordinator?.transport !== transport) {
|
|
6134
|
+
// The transport object changed, but this node's delegations and the handles they admitted
|
|
6135
|
+
// did not. The successor adopts that live authority in its constructor; the predecessor
|
|
6136
|
+
// is closed afterwards so nothing is dropped in between. See LockCoordinatorOptions.adopt.
|
|
6137
|
+
const predecessor = lockCoordinator;
|
|
6138
|
+
lockCoordinator = new LockCoordinator({
|
|
6139
|
+
database: databaseName,
|
|
6140
|
+
table: tableName,
|
|
6141
|
+
nodeId: getThisNodeName(),
|
|
6142
|
+
transport,
|
|
6143
|
+
adopt: predecessor,
|
|
6144
|
+
// Writing to the local transaction log IS the send, so a transport that only computes
|
|
6145
|
+
// the participant set gets core's writer.
|
|
6146
|
+
writeControl: transport.writeControl
|
|
6147
|
+
? (entry: LockControlEntry) => transport.writeControl!(tableName, entry)
|
|
6148
|
+
: (entry: LockControlEntry) => TableResource.writeLockControlEntry(entry),
|
|
6149
|
+
keyIdOf: writeKeyId,
|
|
6150
|
+
nextTimestamp: () => (primaryStore as any).getMonotonicTimestamp(),
|
|
6151
|
+
grantableAfterMono: transport.grantableAfterMono,
|
|
6152
|
+
});
|
|
6153
|
+
predecessor?.close();
|
|
6154
|
+
}
|
|
6155
|
+
return lockCoordinator;
|
|
6156
|
+
}
|
|
5414
6157
|
// #section: validation
|
|
5415
6158
|
validate(record: any, patch?: boolean) {
|
|
5416
6159
|
// Accumulate structured per-field issues so the 400 carries `{ path, code,
|
|
@@ -5652,82 +6395,174 @@ export function makeTable(options) {
|
|
|
5652
6395
|
const exactCount = options?.exactCount;
|
|
5653
6396
|
const TIME_LIMIT = options?.timeLimit ?? 1000 / 2; // one second time limit, enforced by seeing if we are halfway through at 500ms
|
|
5654
6397
|
const start = performance.now();
|
|
5655
|
-
// `entryCount` (the exact key count) is only needed once the scan blows the time budget --
|
|
5656
|
-
// to decide whether to estimate and as the extrapolation base. On RocksDB it is a full
|
|
5657
|
-
// key-only scan, so we defer it: tables that finish within budget (the common case) and
|
|
5658
|
-
// `exact_count` requests never pay for it. `halfway`/`entryCount` stay 0 until first computed.
|
|
5659
6398
|
let entryCount = 0;
|
|
5660
|
-
let
|
|
5661
|
-
let
|
|
5662
|
-
|
|
6399
|
+
let remainderPhysical = 0;
|
|
6400
|
+
let estimator;
|
|
6401
|
+
// feature-detected per DESIGN.md's invariant for this API family; LMDB stores do not implement it
|
|
6402
|
+
const canEstimate =
|
|
6403
|
+
typeof primaryStore.createCountEstimator === 'function' && typeof primaryStore.estimateCount === 'function';
|
|
6404
|
+
let estimatorFailed = false;
|
|
6405
|
+
let warnedNoBase = false;
|
|
6406
|
+
let checkpoints = 0;
|
|
6407
|
+
let checkpointedEntries = 0;
|
|
5663
6408
|
let recordCount = 0;
|
|
5664
6409
|
let entriesScanned = 0;
|
|
6410
|
+
let lastKey;
|
|
5665
6411
|
let limit: number;
|
|
5666
|
-
|
|
6412
|
+
let nextCheckAt = start + TIME_LIMIT;
|
|
6413
|
+
for (const { key, value } of primaryStore.getRange({ start: true, lazy: true, snapshot: false })) {
|
|
5667
6414
|
if (value != null) recordCount++;
|
|
5668
6415
|
entriesScanned++;
|
|
6416
|
+
lastKey = key;
|
|
5669
6417
|
await rest();
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
6418
|
+
// a table too small to reach the floor is small enough to finish exactly
|
|
6419
|
+
if (exactCount || entriesScanned < MIN_ESTIMATOR_SAMPLE) continue;
|
|
6420
|
+
const now = performance.now();
|
|
6421
|
+
if (now <= nextCheckAt) continue;
|
|
6422
|
+
nextCheckAt = now + TIME_LIMIT;
|
|
6423
|
+
checkpoints++;
|
|
6424
|
+
if (canEstimate && !estimatorFailed) {
|
|
6425
|
+
try {
|
|
6426
|
+
estimator ??= primaryStore.createCountEstimator({ start: true });
|
|
6427
|
+
estimator.advance(lastKey, entriesScanned - checkpointedEntries);
|
|
6428
|
+
checkpointedEntries = entriesScanned;
|
|
6429
|
+
entryCount = usableCount(estimator.estimate());
|
|
6430
|
+
} catch (error) {
|
|
6431
|
+
// a store closing concurrently -- drop_table can, while this scan is parked in a yield
|
|
6432
|
+
logger.debug?.('Count estimator unavailable, falling back to an exact scan', error);
|
|
6433
|
+
estimatorFailed = true;
|
|
6434
|
+
estimator = undefined;
|
|
6435
|
+
entryCount = 0;
|
|
5677
6436
|
}
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
6437
|
+
} else if (!canEstimate) {
|
|
6438
|
+
// `canEstimate` false is not the same as "LMDB": a RocksDB store whose native module predates
|
|
6439
|
+
// the estimator API lands here too, and `RocksDatabase.getStats()` carries no `entryCount`.
|
|
6440
|
+
try {
|
|
6441
|
+
const stats = primaryStore.getStats?.();
|
|
6442
|
+
entryCount = Number.isFinite(stats?.entryCount) && stats.entryCount > 0 ? stats.entryCount : 0;
|
|
6443
|
+
} catch {
|
|
6444
|
+
entryCount = 0;
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
if (!entryCount && canEstimate && !estimatorFailed) {
|
|
6448
|
+
// Range estimates are block-granular and can report 0 for a store whose entries are still
|
|
6449
|
+
// in the memtable. Without a base the escape cannot fire at all, so fall back to the
|
|
6450
|
+
// whole-store property rather than silently walking the table.
|
|
6451
|
+
try {
|
|
6452
|
+
const wholeStore = primaryStore.getEstimatedKeyCount();
|
|
6453
|
+
entryCount = Number.isFinite(wholeStore) && wholeStore > 0 ? wholeStore : 0;
|
|
6454
|
+
} catch {
|
|
6455
|
+
entryCount = 0;
|
|
6456
|
+
}
|
|
6457
|
+
if (!entryCount && !warnedNoBase) {
|
|
6458
|
+
warnedNoBase = true;
|
|
6459
|
+
logger.debug?.(`No usable key-count estimate for ${tableName}; counting records by full scan`);
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
// Zero is "no usable base": degrade to the exact scan. The checkpoint ceiling is what stops a
|
|
6463
|
+
// base that keeps undershooting from holding the halfway test false forever and walking the
|
|
6464
|
+
// whole table; the reverse sample is bounded by `limit` in turn.
|
|
6465
|
+
if (
|
|
6466
|
+
entryCount > 0 &&
|
|
6467
|
+
(checkpoints >= MAX_ESTIMATE_CHECKPOINTS || entriesScanned < Math.floor(entryCount / 2))
|
|
6468
|
+
) {
|
|
6469
|
+
if (canEstimate) {
|
|
6470
|
+
try {
|
|
6471
|
+
const remaining = primaryStore.estimateCount({ start: lastKey, exclusiveStart: true });
|
|
6472
|
+
// widened by its own reported untrustworthiness: block-granular, so it can land below
|
|
6473
|
+
// the live count it is meant to bound
|
|
6474
|
+
const remainingCount = usableCount(remaining);
|
|
6475
|
+
remainderPhysical = remainingCount > 0 ? remainingCount * (2 - remaining.confidence) : 0;
|
|
6476
|
+
} catch {
|
|
6477
|
+
remainderPhysical = 0;
|
|
6478
|
+
}
|
|
6479
|
+
// A zero or unusable remainder is valid -- entries still in the memtable read as none
|
|
6480
|
+
// through range statistics -- but it would leave `baseMax` resting on the sampled ends
|
|
6481
|
+
// alone. The whole-store property is a separate, non-range source, so fall back to it.
|
|
6482
|
+
if (!remainderPhysical) {
|
|
6483
|
+
try {
|
|
6484
|
+
const wholeStore = primaryStore.getEstimatedKeyCount();
|
|
6485
|
+
if (Number.isFinite(wholeStore)) remainderPhysical = Math.max(wholeStore - entriesScanned, 0);
|
|
6486
|
+
} catch {
|
|
6487
|
+
remainderPhysical = 0;
|
|
6488
|
+
}
|
|
6489
|
+
}
|
|
5682
6490
|
}
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
completeForExact = true;
|
|
6491
|
+
limit = entriesScanned;
|
|
6492
|
+
break;
|
|
5686
6493
|
}
|
|
5687
6494
|
}
|
|
5688
6495
|
if (limit) {
|
|
5689
6496
|
// in this case we are going to make an estimate of the table count using the first thousand
|
|
5690
6497
|
// entries and last thousand entries
|
|
5691
6498
|
const firstRecordCount = recordCount;
|
|
6499
|
+
const firstKey = lastKey;
|
|
5692
6500
|
recordCount = 0;
|
|
5693
6501
|
// Bound the reverse scan explicitly. The getRange `limit` option is honored by lmdb-js but
|
|
5694
6502
|
// ignored by rocksdb-js; without this break the scan reads the whole table, so `recordRate`
|
|
5695
6503
|
// blows up to ~entryCount/(2*limit) and the estimate scales with entryCount^2 -- the source
|
|
5696
6504
|
// of the wildly inflated `record_count` (e.g. 20,000,000 for ~105k rows) on large RocksDB
|
|
5697
|
-
// tables.
|
|
6505
|
+
// tables.
|
|
5698
6506
|
let reverseScanned = 0;
|
|
5699
|
-
|
|
6507
|
+
// Sized independently of the forward scan. `entriesScanned` is whatever the forward pass
|
|
6508
|
+
// covered before it escaped, and the checkpoint ceiling lets that run twenty budget
|
|
6509
|
+
// intervals when the base keeps undershooting; matching it here would read that same count
|
|
6510
|
+
// again and double the wall clock of the call this path exists to bound.
|
|
6511
|
+
const reverseLimit = Math.min(limit, MIN_ESTIMATOR_SAMPLE);
|
|
6512
|
+
// Disjointness is enforced against the forward scan's own last key rather than inferred from
|
|
6513
|
+
// the base, which is an estimate that can overshoot by more than 2x.
|
|
6514
|
+
let sampledWholeTable = false;
|
|
6515
|
+
for (const { key, value } of primaryStore.getRange({
|
|
5700
6516
|
start: '\uffff',
|
|
5701
6517
|
reverse: true,
|
|
5702
6518
|
lazy: true,
|
|
5703
|
-
limit,
|
|
6519
|
+
limit: reverseLimit,
|
|
5704
6520
|
snapshot: false,
|
|
5705
6521
|
})) {
|
|
6522
|
+
if (compareKeys(key, firstKey) <= 0) {
|
|
6523
|
+
sampledWholeTable = true;
|
|
6524
|
+
break;
|
|
6525
|
+
}
|
|
5706
6526
|
if (value != null) recordCount++;
|
|
5707
6527
|
reverseScanned++;
|
|
5708
6528
|
await rest();
|
|
5709
|
-
if (reverseScanned >=
|
|
6529
|
+
if (reverseScanned >= reverseLimit) break;
|
|
5710
6530
|
}
|
|
6531
|
+
// the samples met, so between them they covered every entry
|
|
6532
|
+
if (sampledWholeTable) return { recordCount: recordCount + firstRecordCount };
|
|
5711
6533
|
// Use the actual entries sampled, not limit*2: the reverse scan can yield fewer than `limit`
|
|
5712
6534
|
// (concurrent deletions under snapshot:false, or an overestimated entryCount), and counting
|
|
5713
6535
|
// those un-scanned slots would inflate the denominator and underestimate the rate.
|
|
5714
|
-
const
|
|
5715
|
-
const recordRate = (
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
//
|
|
5722
|
-
|
|
5723
|
-
const
|
|
5724
|
-
const
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
6536
|
+
const sampledRecords = recordCount + firstRecordCount;
|
|
6537
|
+
const recordRate = sampledRecords / (limit + reverseScanned);
|
|
6538
|
+
// Endpoints for the extrapolation base, spanning both ways an estimated base can be wrong:
|
|
6539
|
+
// every remaining entry superseded (only what was sampled is live) through every remaining
|
|
6540
|
+
// entry live (the uncalibrated physical count). Churn concentrated outside the sampled ends
|
|
6541
|
+
// calibrates to nothing, so an interval derived from the estimator's confidence would sit
|
|
6542
|
+
// narrowly around the wrong number. Both endpoints are themselves estimates on RocksDB, so
|
|
6543
|
+
// this is a widened heuristic interval, not a guaranteed bound on the live count.
|
|
6544
|
+
const baseMin = entriesScanned + reverseScanned;
|
|
6545
|
+
const baseMax = Math.max(entriesScanned + remainderPhysical, entryCount, baseMin);
|
|
6546
|
+
const estimatedRecordCount = Math.round(recordRate * Math.max(entryCount, baseMin));
|
|
6547
|
+
// The samples counted these directly, and the entries between them can only add; everything
|
|
6548
|
+
// outside the samples could be live. A statistical interval inside those endpoints would be
|
|
6549
|
+
// narrowest exactly where the ends are least representative of the middle -- sampled ends
|
|
6550
|
+
// that are all deletion entries give a rate of 0, collapsing an upper end to ~0 with live
|
|
6551
|
+
// rows in between -- so the endpoints are the evidence itself.
|
|
6552
|
+
const lower = sampledRecords;
|
|
6553
|
+
const upper = Math.round(baseMax);
|
|
6554
|
+
// Report only the precision the interval supports, but never so coarse a unit that the
|
|
6555
|
+
// estimate rounds away: `baseMax` is physical and can exceed a calibrated estimate by
|
|
6556
|
+
// orders of magnitude, which a single division cannot walk back.
|
|
6557
|
+
let significantUnit = Math.pow(10, Math.round(Math.log10(Math.max((upper - lower) / 2, 1))));
|
|
6558
|
+
while (significantUnit > estimatedRecordCount && significantUnit > 1) significantUnit /= 10;
|
|
6559
|
+
recordCount = Math.min(
|
|
6560
|
+
Math.max(Math.round(estimatedRecordCount / significantUnit) * significantUnit, lower),
|
|
6561
|
+
upper
|
|
6562
|
+
);
|
|
5728
6563
|
return {
|
|
5729
6564
|
recordCount,
|
|
5730
|
-
estimatedRange: [
|
|
6565
|
+
estimatedRange: [lower, upper],
|
|
5731
6566
|
};
|
|
5732
6567
|
}
|
|
5733
6568
|
return {
|
|
@@ -5741,6 +6576,7 @@ export function makeTable(options) {
|
|
|
5741
6576
|
// Refresh on every call: schema reload mutates `attributes` in place, so the
|
|
5742
6577
|
// class-construction snapshot would otherwise go stale.
|
|
5743
6578
|
this.embedAttributes = (this.attributes as any[]).filter((a) => a?.embed);
|
|
6579
|
+
expiresAtProperty = this.attributes.find((attribute) => attribute.expiresAt);
|
|
5744
6580
|
// Drop registry entries for attributes that are no longer `@embed`, so a dropped
|
|
5745
6581
|
// directive doesn't leave a stale embedder or block a default refresh on re-add.
|
|
5746
6582
|
const embedNames = new Set(this.embedAttributes.map((a) => a.name));
|
|
@@ -5810,7 +6646,7 @@ export function makeTable(options) {
|
|
|
5810
6646
|
txnForContext(context).getReadTxn(),
|
|
5811
6647
|
false,
|
|
5812
6648
|
relatedTable,
|
|
5813
|
-
false
|
|
6649
|
+
{ allowFullScan: false }
|
|
5814
6650
|
) as any
|
|
5815
6651
|
).map((entry) => {
|
|
5816
6652
|
if (entry && entry.key !== undefined) return entry;
|
|
@@ -6058,10 +6894,24 @@ export function makeTable(options) {
|
|
|
6058
6894
|
}
|
|
6059
6895
|
const drainRemovals = () => Promise.all(inFlightRemovals);
|
|
6060
6896
|
let entriesDeleted = 0;
|
|
6897
|
+
// LMDB only: RocksTransactionLogStore.remove() is a no-op, so a RocksDB deleteHistory removes
|
|
6898
|
+
// nothing and must not claim it did.
|
|
6899
|
+
// A bound above everything reachable must not be recorded as the floor: the floor only rises
|
|
6900
|
+
// and a store with a record is never re-stamped, so it would never come down, for every table in
|
|
6901
|
+
// this database. `boundedAuditPruneEnd` clamps the cutoff to just above the newest key in the
|
|
6902
|
+
// log, and the scan below uses that same value as its range end, so the prune cannot remove an
|
|
6903
|
+
// entry the floor does not cover.
|
|
6904
|
+
let pruneEnd = endTime;
|
|
6905
|
+
if (!isRocksDB) {
|
|
6906
|
+
pruneEnd = boundedAuditPruneEnd(auditStore, endTime);
|
|
6907
|
+
raiseAuditFloor(auditStore, pruneEnd);
|
|
6908
|
+
}
|
|
6061
6909
|
try {
|
|
6062
6910
|
for (const auditRecord of auditStore.getRange({
|
|
6063
|
-
|
|
6064
|
-
|
|
6911
|
+
// must not be zero: 0 encodes to all zero bytes and so overlaps the symbol keys, as in
|
|
6912
|
+
// getHistory below
|
|
6913
|
+
start: 1,
|
|
6914
|
+
end: pruneEnd,
|
|
6065
6915
|
})) {
|
|
6066
6916
|
await rest(); // yield to other async operations
|
|
6067
6917
|
if (auditRecord.tableId !== tableId) continue;
|
|
@@ -6088,7 +6938,7 @@ export function makeTable(options) {
|
|
|
6088
6938
|
isRocksDB && version != null
|
|
6089
6939
|
? resolveAuditHead(key, version, entry.nodeId, entry.additionalAuditRefs).txnLogKey
|
|
6090
6940
|
: localTime;
|
|
6091
|
-
if (value === null && version != null && auditTime <
|
|
6941
|
+
if (value === null && version != null && auditTime < pruneEnd) {
|
|
6092
6942
|
const backpressure = queueRemoval(
|
|
6093
6943
|
() => primaryStore.remove(key, version),
|
|
6094
6944
|
'Error removing deleted record during deleteHistory'
|
|
@@ -6113,7 +6963,8 @@ export function makeTable(options) {
|
|
|
6113
6963
|
end: endTime,
|
|
6114
6964
|
})) {
|
|
6115
6965
|
await rest(); // yield to other async operations
|
|
6116
|
-
if (auditRecord.tableId !== tableId)
|
|
6966
|
+
if (auditRecord.tableId !== tableId || auditRecord.type === 'evict' || isLockControlType(auditRecord.type))
|
|
6967
|
+
continue;
|
|
6117
6968
|
yield {
|
|
6118
6969
|
id: auditRecord.recordId,
|
|
6119
6970
|
// Compatibility-facing LMDB history has always reported/grouped by record version.
|
|
@@ -6143,7 +6994,12 @@ export function makeTable(options) {
|
|
|
6143
6994
|
let highestPreviousVersion = 0;
|
|
6144
6995
|
const start = nextVersion - auditWindow;
|
|
6145
6996
|
for (const auditRecord of auditStore.getRange({ start, end: nextVersion + 0.001 })) {
|
|
6146
|
-
if (
|
|
6997
|
+
if (
|
|
6998
|
+
auditRecord.tableId === tableId &&
|
|
6999
|
+
auditRecord.type !== 'evict' &&
|
|
7000
|
+
!isLockControlType(auditRecord.type) &&
|
|
7001
|
+
compareKeys(auditRecord.recordId, id) === 0
|
|
7002
|
+
) {
|
|
6147
7003
|
history.splice(insertionPoint, 0, {
|
|
6148
7004
|
id: auditRecord.recordId,
|
|
6149
7005
|
localTime: isRocksDB ? auditRecord.txnLogKey : auditRecord.version,
|
|
@@ -6179,6 +7035,7 @@ export function makeTable(options) {
|
|
|
6179
7035
|
const promises = [primaryStore.clear()];
|
|
6180
7036
|
for (const key in indices) {
|
|
6181
7037
|
const index = indices[key];
|
|
7038
|
+
index.customIndex?.resetDerivedStorage?.();
|
|
6182
7039
|
promises.push(index.clearAsync ? index.clearAsync() : index.clear());
|
|
6183
7040
|
}
|
|
6184
7041
|
return Promise.all(promises);
|
|
@@ -6186,6 +7043,7 @@ export function makeTable(options) {
|
|
|
6186
7043
|
/** Release everything makeTable() registered process-wide; the class must not be used afterwards. */
|
|
6187
7044
|
static cleanup() {
|
|
6188
7045
|
disposed = true;
|
|
7046
|
+
void TableResource.derivedIndexRuntime?.close();
|
|
6189
7047
|
clearTimeout(cleanupTimer);
|
|
6190
7048
|
settlePendingCleanup();
|
|
6191
7049
|
clearInterval(recordExpirationInterval);
|
|
@@ -6214,8 +7072,15 @@ export function makeTable(options) {
|
|
|
6214
7072
|
|
|
6215
7073
|
try {
|
|
6216
7074
|
TableResource.updatedAttributes(); // on creation, update accessors as well
|
|
6217
|
-
if (expirationMs)
|
|
6218
|
-
|
|
7075
|
+
if (expirationMs) {
|
|
7076
|
+
ttlFromLoad = true;
|
|
7077
|
+
try {
|
|
7078
|
+
TableResource.setTTLExpiration(expirationMs / 1000);
|
|
7079
|
+
} finally {
|
|
7080
|
+
ttlFromLoad = false;
|
|
7081
|
+
}
|
|
7082
|
+
}
|
|
7083
|
+
if (expiresAtProperty && !recordExpirationInterval) runRecordExpirationEviction();
|
|
6219
7084
|
} catch (error) {
|
|
6220
7085
|
TableResource.cleanup();
|
|
6221
7086
|
throw error;
|
|
@@ -6980,25 +7845,34 @@ export function makeTable(options) {
|
|
|
6980
7845
|
}
|
|
6981
7846
|
resolve(resolvedEntry);
|
|
6982
7847
|
} catch (error) {
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7848
|
+
// A source may reject with anything at all, so deciding how to settle is itself
|
|
7849
|
+
// fallible: `message` is not assignable on every error (a DOMException from
|
|
7850
|
+
// AbortSignal.timeout), and a nullish rejection makes the reads below throw.
|
|
7851
|
+
// Leaving this promise unsettled hangs the caller forever, so every path here
|
|
7852
|
+
// has to end in resolve() or reject().
|
|
7853
|
+
try {
|
|
7854
|
+
appendErrorContext(error, ` while resolving record ${id} for ${tableName}`);
|
|
7855
|
+
if (
|
|
7856
|
+
existingRecord &&
|
|
7857
|
+
(((error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'EAI_AGAIN') &&
|
|
7858
|
+
!context?.mustRevalidate) ||
|
|
7859
|
+
(context?.staleIfError &&
|
|
7860
|
+
(error.statusCode === 500 ||
|
|
7861
|
+
error.statusCode === 502 ||
|
|
7862
|
+
error.statusCode === 503 ||
|
|
7863
|
+
error.statusCode === 504)))
|
|
7864
|
+
) {
|
|
7865
|
+
// these are conditions under which we can use stale data after an error
|
|
7866
|
+
resolve({
|
|
7867
|
+
key: id,
|
|
7868
|
+
version: existingVersion,
|
|
7869
|
+
value: existingRecord,
|
|
7870
|
+
} as any);
|
|
7871
|
+
logger.trace?.((error as Error)?.message, '(returned stale record)');
|
|
7872
|
+
} else reject(error);
|
|
7873
|
+
} catch (settlingError) {
|
|
7874
|
+
reject(error ?? settlingError);
|
|
7875
|
+
}
|
|
7002
7876
|
const resolveDuration = performance.now() - start;
|
|
7003
7877
|
recordAction(resolveDuration, 'cache-resolution', tableName, null, 'fail');
|
|
7004
7878
|
if (responseHeaders)
|
|
@@ -7238,6 +8112,7 @@ export function makeTable(options) {
|
|
|
7238
8112
|
if (entry.value == null) continue; // already removed
|
|
7239
8113
|
if (hasSourceGet && primaryStore.hasLock(item.key, entry.version)) continue; // resolution in progress
|
|
7240
8114
|
updateIndices(item.key, entry.value, null, options);
|
|
8115
|
+
stageDerivedIndexEviction(transaction, item.key, entry.version);
|
|
7241
8116
|
}
|
|
7242
8117
|
removeEntry(primaryStore, entry, options);
|
|
7243
8118
|
staged++;
|
|
@@ -7328,7 +8203,7 @@ export function makeTable(options) {
|
|
|
7328
8203
|
// Periodically evict expired records and deleted records searching for records who expiresAt timestamp is before now
|
|
7329
8204
|
if (cleanupInterval === lastCleanupInterval && !runImmediately) return;
|
|
7330
8205
|
lastCleanupInterval = cleanupInterval;
|
|
7331
|
-
if (
|
|
8206
|
+
if (ownsStoreMaintenance(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
|
|
7332
8207
|
// run on the last thread so we aren't overloading lower-numbered threads
|
|
7333
8208
|
if (cleanupTimer) clearTimeout(cleanupTimer);
|
|
7334
8209
|
if (!cleanupInterval) {
|
|
@@ -7470,12 +8345,14 @@ export function makeTable(options) {
|
|
|
7470
8345
|
}
|
|
7471
8346
|
function runRecordExpirationEviction() {
|
|
7472
8347
|
// Periodically evict expired records, searching for records who expiresAt timestamp is before now
|
|
7473
|
-
if (
|
|
8348
|
+
if (ownsStoreExpiration(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
|
|
7474
8349
|
// we want to run the pruning of expired records on only one thread so we don't have conflicts in evicting
|
|
7475
8350
|
recordExpirationInterval = setInterval(async () => {
|
|
7476
8351
|
// go through each database and table and then search for expired entries
|
|
7477
8352
|
// find any entries that are set to expire before now
|
|
7478
|
-
|
|
8353
|
+
// updatedAttributes() clears expiresAtProperty when a live redeclaration drops the directive,
|
|
8354
|
+
// and there is nothing left for this interval to scan by
|
|
8355
|
+
if (disposed || runningRecordExpiration || !expiresAtProperty) return;
|
|
7479
8356
|
runningRecordExpiration = true;
|
|
7480
8357
|
try {
|
|
7481
8358
|
const expiresAtName = expiresAtProperty.name;
|