@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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/CHANGELOG.md +177 -5
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +71 -18
- package/dist/storage/MongoBucketStorage.js +516 -202
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
- package/dist/storage/implementation/MongoBucketBatch.js +140 -66
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
- package/dist/storage/implementation/MongoChecksums.js +36 -121
- package/dist/storage/implementation/MongoChecksums.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
- package/dist/storage/implementation/MongoCompactor.js +87 -481
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
- package/dist/storage/implementation/MongoHydrationState.js +49 -0
- package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
- package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
- package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
- package/dist/storage/implementation/MongoStorageProvider.js +41 -4
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
- package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
- package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
- package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +28 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
- package/dist/storage/implementation/common/PersistedBatch.js +63 -10
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
- package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
- package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +4 -1
- package/dist/storage/implementation/db.js +31 -16
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +55 -13
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
- package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
- package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
- package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +33 -27
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
- package/dist/storage/implementation/v1/models.d.ts +10 -1
- package/dist/storage/implementation/v1/models.js +1 -0
- package/dist/storage/implementation/v1/models.js.map +1 -1
- package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
- package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
- package/dist/storage/implementation/v3/CompactionLease.js +131 -0
- package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +16 -5
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
- package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +8 -3
- package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
- package/dist/storage/implementation/v3/bucket-format.js +79 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
- package/dist/storage/implementation/v3/chunking.d.ts +10 -0
- package/dist/storage/implementation/v3/chunking.js +30 -0
- package/dist/storage/implementation/v3/chunking.js.map +1 -0
- package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
- package/dist/storage/implementation/v3/compact-utils.js +216 -0
- package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
- package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
- package/dist/storage/implementation/v3/compaction-constants.js +6 -0
- package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
- package/dist/storage/implementation/v3/models.d.ts +187 -23
- package/dist/storage/implementation/v3/models.js +11 -28
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
- package/dist/storage/implementation/v3/source-table-utils.js +206 -0
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
- package/dist/storage/storage-index.d.ts +5 -4
- package/dist/storage/storage-index.js +5 -4
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +71 -3
- package/dist/types/types.js +58 -2
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.d.ts +1 -2
- package/dist/utils/test-utils.js +7 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +9 -2
- package/dist/utils/util.js +131 -5
- package/dist/utils/util.js.map +1 -1
- package/package.json +13 -19
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +728 -242
- package/src/storage/implementation/MongoBucketBatch.ts +196 -91
- package/src/storage/implementation/MongoChecksums.ts +93 -153
- package/src/storage/implementation/MongoCompactor.ts +106 -631
- package/src/storage/implementation/MongoHydrationState.ts +61 -0
- package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
- package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
- package/src/storage/implementation/MongoStorageProvider.ts +48 -4
- package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
- package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
- package/src/storage/implementation/common/PersistedBatch.ts +83 -15
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
- package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
- package/src/storage/implementation/db.ts +40 -22
- package/src/storage/implementation/models.ts +58 -14
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
- package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
- package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
- package/src/storage/implementation/v1/models.ts +12 -1
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
- package/src/storage/implementation/v3/bucket-format.ts +99 -0
- package/src/storage/implementation/v3/chunking.ts +40 -0
- package/src/storage/implementation/v3/compact-utils.ts +320 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +228 -58
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
- package/src/storage/implementation/v3/source-table-utils.ts +398 -0
- package/src/storage/storage-index.ts +14 -4
- package/src/types/types.ts +76 -2
- package/src/utils/test-utils.ts +8 -3
- package/src/utils/util.ts +188 -7
- package/test/src/__snapshots__/storage.test.ts.snap +4 -54
- package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
- package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +61 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/object_storage_usage.test.ts +255 -0
- package/test/src/parameter_compacting_v1.test.ts +120 -0
- package/test/src/parameter_compacting_v3.test.ts +192 -0
- package/test/src/parameter_compaction_fence.test.ts +276 -0
- package/test/src/setup.ts +6 -1
- package/test/src/storage.test.ts +478 -13
- package/test/src/storage_compacting.test.ts +2516 -107
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +190 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
- package/test/src/storage_s3_reading.test.ts +768 -0
- package/test/src/storage_s3_writing.test.ts +136 -0
- package/test/src/storage_sync.test.ts +1507 -76
- package/test/src/storeCurrentData.test.ts +227 -0
- package/test/src/util.ts +3 -2
- package/tsconfig.json +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
- package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
- package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
- package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
- package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
- package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
- package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
- package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
- package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
- package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
- package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
- package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
- package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
- package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
- package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
|
@@ -1,8 +1,52 @@
|
|
|
1
|
+
import { BucketDataDoc } from '@module/storage/implementation/common/BucketDataDoc.js';
|
|
2
|
+
import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
|
|
3
|
+
import { loadBucketDataDocument, serializeBucketData } from '@module/storage/implementation/v3/bucket-format.js';
|
|
4
|
+
import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
|
|
5
|
+
import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from '@module/storage/implementation/v3/compaction-constants.js';
|
|
6
|
+
import { CompactionLease } from '@module/storage/implementation/v3/CompactionLease.js';
|
|
7
|
+
import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
|
|
8
|
+
import { ObjectStorageError } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
|
|
1
9
|
import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
10
|
+
import { replicaIdToSubkey } from '@module/utils/util.js';
|
|
11
|
+
import { logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
12
|
+
import {
|
|
13
|
+
addChecksums,
|
|
14
|
+
CheckpointChecksumInvalidatedError,
|
|
15
|
+
storage,
|
|
16
|
+
SyncRulesBucketStorage,
|
|
17
|
+
updateSyncRulesFromYaml
|
|
18
|
+
} from '@powersync/service-core';
|
|
19
|
+
import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
|
|
20
|
+
import * as bson from 'bson';
|
|
21
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
22
|
+
import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
|
|
23
|
+
|
|
24
|
+
function makeOp(
|
|
25
|
+
opId: number,
|
|
26
|
+
rowId: string,
|
|
27
|
+
data: string,
|
|
28
|
+
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
29
|
+
sourceTableId: bson.ObjectId,
|
|
30
|
+
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
31
|
+
): BucketDataDoc {
|
|
32
|
+
const sourceKey = test_utils.rid(rowId);
|
|
33
|
+
return {
|
|
34
|
+
bucketKey: {
|
|
35
|
+
replicationStreamId: ctx.replicationStreamId,
|
|
36
|
+
definitionId: ctx.definitionId,
|
|
37
|
+
bucket: ctx.bucket
|
|
38
|
+
},
|
|
39
|
+
o: BigInt(opId),
|
|
40
|
+
op: overrides?.op ?? 'PUT',
|
|
41
|
+
source_table: sourceTableId,
|
|
42
|
+
source_key: sourceKey,
|
|
43
|
+
subkey: replicaIdToSubkey(sourceTableId, sourceKey),
|
|
44
|
+
table: 'items',
|
|
45
|
+
row_id: rowId,
|
|
46
|
+
checksum: BigInt(opId * 7),
|
|
47
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
48
|
+
};
|
|
49
|
+
}
|
|
6
50
|
|
|
7
51
|
describe('Mongo Sync Bucket Storage Compact', () => {
|
|
8
52
|
register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
@@ -46,49 +90,49 @@ describe('Mongo Sync Bucket Storage Compact', () => {
|
|
|
46
90
|
return bucketStorage.getCheckpoint();
|
|
47
91
|
};
|
|
48
92
|
|
|
49
|
-
const setup = async () => {
|
|
93
|
+
const setup = async (storageVersion?: number) => {
|
|
50
94
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
51
95
|
const syncRules = await factory.updateSyncRules(
|
|
52
|
-
updateSyncRulesFromYaml(
|
|
96
|
+
updateSyncRulesFromYaml(
|
|
97
|
+
`
|
|
53
98
|
bucket_definitions:
|
|
54
99
|
by_user:
|
|
55
100
|
parameters: select request.user_id() as user_id
|
|
56
101
|
data: [select * from test where owner_id = bucket.user_id]
|
|
57
|
-
|
|
102
|
+
`,
|
|
103
|
+
{ storageVersion }
|
|
104
|
+
)
|
|
58
105
|
);
|
|
59
106
|
const bucketStorage = factory.getInstance(syncRules);
|
|
107
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
60
108
|
const { checkpoint } = await populate(bucketStorage, 1);
|
|
61
109
|
|
|
62
|
-
return { bucketStorage, checkpoint, factory, syncRules };
|
|
110
|
+
return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
|
|
63
111
|
};
|
|
64
112
|
|
|
65
|
-
test('full compact', async () => {
|
|
113
|
+
test('V1 full compact with blank bucket_state', async () => {
|
|
66
114
|
const { bucketStorage, checkpoint, factory, syncRules } = await setup();
|
|
67
115
|
const storageDb = bucketStorage.db;
|
|
68
116
|
|
|
69
|
-
// Simulate bucket_state from old version not being available
|
|
70
117
|
if (storageDb.storageConfig.incrementalReprocessing) {
|
|
71
|
-
|
|
72
|
-
// Can remove this if it causes issues in the future.
|
|
73
|
-
await (storageDb as VersionedPowerSyncMongoV3).bucketStateV3(bucketStorage.group_id).deleteMany({});
|
|
74
|
-
} else {
|
|
75
|
-
await factory.db.bucket_state.deleteMany({});
|
|
118
|
+
return;
|
|
76
119
|
}
|
|
120
|
+
// Simulate a V1 deployment which pre-dates bucket-state population.
|
|
121
|
+
await factory.db.bucket_state.deleteMany({});
|
|
77
122
|
|
|
78
|
-
await
|
|
123
|
+
await compactActive(factory, {
|
|
79
124
|
clearBatchLimit: 200,
|
|
80
125
|
moveBatchLimit: 10,
|
|
81
126
|
moveBatchQueryLimit: 10,
|
|
82
127
|
minBucketChanges: 1,
|
|
83
128
|
minChangeRatio: 0,
|
|
84
|
-
maxOpId: checkpoint
|
|
85
|
-
signal: null as any
|
|
129
|
+
maxOpId: checkpoint
|
|
86
130
|
});
|
|
87
131
|
|
|
88
132
|
const users = ['u1', 'u2'];
|
|
89
133
|
const userRequests = users.map((user) => bucketRequest(syncRules, `by_user["${user}"]`));
|
|
90
134
|
const [u1Request, u2Request] = userRequests;
|
|
91
|
-
const checksumAfter = await bucketStorage.getChecksums(checkpoint, userRequests);
|
|
135
|
+
const checksumAfter = await bucketStorage.getChecksums(test_utils.testCheckpoint(checkpoint), userRequests);
|
|
92
136
|
expect(checksumAfter.get(u1Request.bucket)).toEqual({
|
|
93
137
|
bucket: u1Request.bucket,
|
|
94
138
|
checksum: -659469718,
|
|
@@ -101,49 +145,54 @@ bucket_definitions:
|
|
|
101
145
|
});
|
|
102
146
|
});
|
|
103
147
|
|
|
104
|
-
test('
|
|
148
|
+
test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
|
|
105
149
|
// Populate old replication stream
|
|
106
|
-
const { factory } = await setup();
|
|
150
|
+
const { factory } = await setup(storageVersion);
|
|
107
151
|
|
|
108
152
|
// Now populate another replication stream (bucket definition name changed)
|
|
109
153
|
const syncRules = await factory.updateSyncRules(
|
|
110
|
-
updateSyncRulesFromYaml(
|
|
154
|
+
updateSyncRulesFromYaml(
|
|
155
|
+
`
|
|
111
156
|
bucket_definitions:
|
|
112
157
|
by_user2:
|
|
113
158
|
parameters: select request.user_id() as user_id
|
|
114
159
|
data: [select * from test where owner_id = bucket.user_id]
|
|
115
|
-
|
|
160
|
+
`,
|
|
161
|
+
{ storageVersion }
|
|
162
|
+
)
|
|
116
163
|
);
|
|
117
164
|
const bucketStorage = factory.getInstance(syncRules);
|
|
118
|
-
const
|
|
165
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
119
166
|
|
|
120
167
|
await populate(bucketStorage, 2);
|
|
121
168
|
const { checkpoint } = await bucketStorage.getCheckpoint();
|
|
122
169
|
|
|
123
|
-
//
|
|
124
|
-
|
|
170
|
+
// V3's initial lite pass processes every bucket with no prior compact state.
|
|
171
|
+
// Earlier storage versions use the default minimum-change threshold.
|
|
172
|
+
const result0 = await bucketStorage.compactInitialReplication({
|
|
125
173
|
maxOpId: checkpoint
|
|
126
174
|
});
|
|
127
|
-
expect(result0.buckets).toEqual(0);
|
|
175
|
+
expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
|
|
128
176
|
|
|
129
|
-
//
|
|
130
|
-
|
|
177
|
+
// For V1/V2, lower the threshold to populate the checksum cache. V3 has
|
|
178
|
+
// already updated its compacted state, so another initial pass is a no-op.
|
|
179
|
+
const result1 = await bucketStorage.compactInitialReplication({
|
|
131
180
|
maxOpId: checkpoint,
|
|
132
181
|
minBucketChanges: 1
|
|
133
182
|
});
|
|
134
|
-
expect(result1.buckets).toEqual(2);
|
|
183
|
+
expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
|
|
135
184
|
|
|
136
|
-
//
|
|
137
|
-
const result2 = await bucketStorage.
|
|
185
|
+
// Repeating it stays a no-op.
|
|
186
|
+
const result2 = await bucketStorage.compactInitialReplication({
|
|
138
187
|
maxOpId: checkpoint,
|
|
139
188
|
minBucketChanges: 1
|
|
140
189
|
});
|
|
141
190
|
expect(result2.buckets).toEqual(0);
|
|
142
191
|
|
|
143
192
|
const users = ['u1', 'u2'];
|
|
144
|
-
const userRequests = users.map((user) => bucketRequest(
|
|
193
|
+
const userRequests = users.map((user) => bucketRequest(syncRulesContent, `by_user2["${user}"]`));
|
|
145
194
|
const [u1Request, u2Request] = userRequests;
|
|
146
|
-
const checksumAfter = await bucketStorage.getChecksums(checkpoint, userRequests);
|
|
195
|
+
const checksumAfter = await bucketStorage.getChecksums(test_utils.testCheckpoint(checkpoint), userRequests);
|
|
147
196
|
expect(checksumAfter.get(u1Request.bucket)).toEqual({
|
|
148
197
|
bucket: u1Request.bucket,
|
|
149
198
|
checksum: -659469718,
|
|
@@ -156,90 +205,2450 @@ bucket_definitions:
|
|
|
156
205
|
});
|
|
157
206
|
});
|
|
158
207
|
|
|
159
|
-
test('
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
208
|
+
test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
|
|
209
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
210
|
+
const result = await (bucketStorage as MongoSyncBucketStorage)
|
|
211
|
+
.createMongoCompactor({
|
|
212
|
+
maxOpId: checkpoint,
|
|
213
|
+
compactChunksOnly: true,
|
|
214
|
+
minCompactChunkIntervalMs: 1
|
|
215
|
+
})
|
|
216
|
+
.compact();
|
|
217
|
+
|
|
218
|
+
expect(result).toBe(2);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
|
|
222
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
223
|
+
const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
|
|
224
|
+
expect(firstResult.buckets).toBe(2);
|
|
225
|
+
|
|
226
|
+
const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
|
|
227
|
+
bucketStorage.replicationStreamId
|
|
167
228
|
);
|
|
168
|
-
|
|
229
|
+
await bucketStateCollection.updateMany(
|
|
230
|
+
{},
|
|
231
|
+
{
|
|
232
|
+
$set: {
|
|
233
|
+
first_uncompacted_write: new Date(0),
|
|
234
|
+
next_compact_check: new Date(0)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
|
|
240
|
+
.aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
241
|
+
.toArray();
|
|
242
|
+
const result = await bucketStorage.compactInitialReplication({
|
|
243
|
+
maxOpId: checkpoint,
|
|
244
|
+
signal: AbortSignal.timeout(2_000)
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(result.buckets).toBe(0);
|
|
248
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
249
|
+
expect(states).toHaveLength(2);
|
|
250
|
+
for (const state of states) {
|
|
251
|
+
expect(state.next_compact_check!.getTime()).toBeGreaterThan(
|
|
252
|
+
now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('v3 replication writes initialize scheduled compaction state', async () => {
|
|
258
|
+
const { bucketStorage } = await setup();
|
|
169
259
|
const storageDb = bucketStorage.db;
|
|
170
260
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
await factory.db.bucket_state.insertOne({
|
|
195
|
-
_id: {
|
|
196
|
-
g: bucketStorage.group_id,
|
|
197
|
-
b: 'global[]'
|
|
198
|
-
},
|
|
199
|
-
last_op: 5n,
|
|
200
|
-
compacted_state: {
|
|
201
|
-
op_id: 3n,
|
|
202
|
-
count: 3,
|
|
203
|
-
checksum: 0n,
|
|
204
|
-
bytes: 7n
|
|
205
|
-
},
|
|
206
|
-
estimate_since_compact: {
|
|
207
|
-
count: 2,
|
|
208
|
-
bytes: 5n
|
|
209
|
-
}
|
|
210
|
-
});
|
|
261
|
+
if (!storageDb.storageConfig.incrementalReprocessing) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
|
|
265
|
+
bucketStorage.replicationStreamId
|
|
266
|
+
);
|
|
267
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
268
|
+
expect(states).toHaveLength(2);
|
|
269
|
+
for (const state of states) {
|
|
270
|
+
expect(state.last_op).toBeGreaterThan(0n);
|
|
271
|
+
expect(state.bucket_stats.count).toBe(1);
|
|
272
|
+
expect(state.bucket_stats.chunks).toBe(1);
|
|
273
|
+
expect(state.first_uncompacted_write).toBeInstanceOf(Date);
|
|
274
|
+
expect(state.next_compact_check).toBeInstanceOf(Date);
|
|
275
|
+
expect(state.compacted_state).toBeUndefined();
|
|
276
|
+
expect(state).not.toHaveProperty('estimate_since_compact');
|
|
277
|
+
|
|
278
|
+
const docs = await (storageDb as VersionedPowerSyncMongoV3)
|
|
279
|
+
.bucketData(bucketStorage.replicationStreamId, state._id.d)
|
|
280
|
+
.find({ '_id.b': state._id.b })
|
|
281
|
+
.toArray();
|
|
282
|
+
expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
|
|
211
283
|
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
212
287
|
|
|
213
|
-
|
|
214
|
-
|
|
288
|
+
describe('Mongo Sync Parameter Storage Compact', () => {
|
|
289
|
+
register.registerParameterCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
290
|
+
});
|
|
215
291
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
292
|
+
/**
|
|
293
|
+
* V3 Invariant Verification
|
|
294
|
+
*
|
|
295
|
+
* Tests in this block exercise two levels:
|
|
296
|
+
*
|
|
297
|
+
* Unit tests (no MongoDB): call serializeBucketData(), chunkBucketData(), or
|
|
298
|
+
* loadBucketDataDocument() directly. These use makeBucketDataDoc() with fake
|
|
299
|
+
* bucket keys and source table IDs.
|
|
300
|
+
*
|
|
301
|
+
* Integration tests (full MongoDB): provision V3 storage, insert pre-serialized
|
|
302
|
+
* documents directly into collections, trigger compaction via
|
|
303
|
+
* bucketStorage.compact(), then read back and verify. Surfaces exercised:
|
|
304
|
+
*
|
|
305
|
+
* - collection.insertMany (direct bucket_data writes)
|
|
306
|
+
* - bucketStateCollection.insertOne (so compactor discovers buckets)
|
|
307
|
+
* - bucketStorage.compact() (full pipeline: dirtyBucketBatches →
|
|
308
|
+
* compactSingleBucket → delete-all + rechunk in transaction →
|
|
309
|
+
* writeBucketStateUpdates)
|
|
310
|
+
* - collection.find with _id.b filters (verify post-compaction state)
|
|
311
|
+
*
|
|
312
|
+
* The initial write path (MongoBucketBatchV3 → flushBucketDataShared →
|
|
313
|
+
* chunkBucketData → serializeBucketData → bulk write) is NOT exercised here —
|
|
314
|
+
* that path is covered by the existing shared tests in register-compacting-tests.ts.
|
|
315
|
+
*/
|
|
316
|
+
describe('V3 invariant verification', () => {
|
|
317
|
+
const BUCKET = 'global[]';
|
|
221
318
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
319
|
+
function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
|
|
320
|
+
const sourceTable = new bson.ObjectId();
|
|
321
|
+
const sourceKey = 'key';
|
|
322
|
+
return {
|
|
323
|
+
bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
|
|
324
|
+
op: 'PUT',
|
|
325
|
+
source_table: sourceTable,
|
|
326
|
+
source_key: sourceKey,
|
|
327
|
+
subkey: replicaIdToSubkey(sourceTable, sourceKey),
|
|
328
|
+
table: 'test',
|
|
329
|
+
row_id: 'row1',
|
|
330
|
+
checksum: 1n,
|
|
331
|
+
data: '{"id":"row1"}',
|
|
332
|
+
...overrides
|
|
333
|
+
};
|
|
334
|
+
}
|
|
228
335
|
|
|
229
|
-
|
|
230
|
-
|
|
336
|
+
async function setupV3Storage() {
|
|
337
|
+
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
338
|
+
const syncRules = await factory.updateSyncRules(
|
|
339
|
+
updateSyncRulesFromYaml(
|
|
340
|
+
`
|
|
341
|
+
bucket_definitions:
|
|
342
|
+
global:
|
|
343
|
+
data: [SELECT id as id, description FROM items]
|
|
344
|
+
`,
|
|
345
|
+
{ storageVersion: 3 }
|
|
346
|
+
)
|
|
347
|
+
);
|
|
348
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
349
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
350
|
+
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
351
|
+
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
352
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
353
|
+
const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
|
|
354
|
+
const sourceTableId = new bson.ObjectId();
|
|
355
|
+
|
|
356
|
+
const ctx = {
|
|
357
|
+
replicationStreamId: bucketStorage.replicationStreamId,
|
|
358
|
+
definitionId,
|
|
359
|
+
bucket: BUCKET
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
366
|
+
await collection.insertMany(docs);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
|
|
370
|
+
await bucketStateCollection.insertOne({
|
|
371
|
+
_id: { d: definitionId, b: BUCKET },
|
|
372
|
+
last_op: lastOp,
|
|
373
|
+
next_compact_check: new Date(0),
|
|
374
|
+
first_uncompacted_write: new Date(0),
|
|
375
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
|
|
380
|
+
await bucketStorage.compact({
|
|
381
|
+
clearBatchLimit: 200,
|
|
382
|
+
moveBatchLimit: 10,
|
|
383
|
+
moveBatchQueryLimit: 10,
|
|
384
|
+
maxOpId
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
test('a successful lease renewal clears a transient renewal error', async () => {
|
|
389
|
+
const { bucketStateCollection, ctx } = await setupV3Storage();
|
|
390
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
|
|
391
|
+
const lease = await CompactionLease.claim(
|
|
392
|
+
bucketStateCollection,
|
|
393
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
394
|
+
undefined,
|
|
395
|
+
10 * 60 * 1000
|
|
396
|
+
);
|
|
397
|
+
expect(lease).not.toBeNull();
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const transientError = new Error('temporary MongoDB error');
|
|
401
|
+
const renew = (lease as any).renew.bind(lease);
|
|
402
|
+
const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
|
|
403
|
+
await renew().catch((error: unknown) => {
|
|
404
|
+
(lease as any).renewalError = error;
|
|
231
405
|
});
|
|
232
|
-
expect(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
]);
|
|
406
|
+
await expect(lease!.throwIfLost()).rejects.toBe(transientError);
|
|
407
|
+
|
|
408
|
+
updateOne.mockRestore();
|
|
409
|
+
await renew();
|
|
410
|
+
await expect(lease!.throwIfLost()).resolves.toBeUndefined();
|
|
411
|
+
} finally {
|
|
412
|
+
await lease?.[Symbol.asyncDispose]();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('a failed scheduled bucket does not block other scheduled buckets', async () => {
|
|
417
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
418
|
+
const badBucket = 'bad[]';
|
|
419
|
+
const goodBucket = 'good[]';
|
|
420
|
+
const goodDocument = serializeBucketData(goodBucket, [
|
|
421
|
+
makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
|
|
422
|
+
]);
|
|
423
|
+
await insertDocs(collection, [goodDocument]);
|
|
424
|
+
await bucketStateCollection.insertMany([
|
|
425
|
+
{
|
|
426
|
+
_id: { d: ctx.definitionId, b: badBucket },
|
|
427
|
+
last_op: 2n,
|
|
428
|
+
next_compact_check: new Date(0),
|
|
429
|
+
// A malformed scheduled bucket with an expired lease must be
|
|
430
|
+
// rescheduled without preventing valid buckets from compacting.
|
|
431
|
+
first_uncompacted_write: new Date(0),
|
|
432
|
+
bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
|
|
433
|
+
compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
_id: { d: ctx.definitionId, b: goodBucket },
|
|
437
|
+
last_op: 2n,
|
|
438
|
+
next_compact_check: new Date(0),
|
|
439
|
+
first_uncompacted_write: new Date(0),
|
|
440
|
+
bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
|
|
441
|
+
}
|
|
442
|
+
]);
|
|
443
|
+
await bucketStateCollection.updateOne(
|
|
444
|
+
{ _id: { d: ctx.definitionId, b: badBucket } },
|
|
445
|
+
{ $unset: { first_uncompacted_write: '' } }
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await (bucketStorage as MongoSyncBucketStorage)
|
|
449
|
+
.createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
|
|
450
|
+
.compact();
|
|
451
|
+
|
|
452
|
+
const [badState, goodState] = await Promise.all([
|
|
453
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
|
|
454
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
|
|
455
|
+
]);
|
|
456
|
+
expect(badState?.next_compact_check).toBeInstanceOf(Date);
|
|
457
|
+
expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
458
|
+
expect(goodState?.compacted_state?.op_id).toBe(2n);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
|
|
462
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
463
|
+
const buckets = ['first[]', 'second[]', 'third[]'];
|
|
464
|
+
const documents = buckets.map((bucket) =>
|
|
465
|
+
serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
|
|
466
|
+
);
|
|
467
|
+
await insertDocs(collection, documents);
|
|
468
|
+
await bucketStateCollection.insertMany(
|
|
469
|
+
documents.map((document, index) => ({
|
|
470
|
+
_id: { d: ctx.definitionId, b: buckets[index] },
|
|
471
|
+
last_op: 2n,
|
|
472
|
+
next_compact_check: new Date(0),
|
|
473
|
+
first_uncompacted_write: new Date(0),
|
|
474
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
475
|
+
}))
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const abortController = new AbortController();
|
|
479
|
+
const testLogger = defaultLogger.child({});
|
|
480
|
+
vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
|
|
481
|
+
if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
|
|
482
|
+
abortController.abort();
|
|
483
|
+
}
|
|
484
|
+
return testLogger;
|
|
485
|
+
});
|
|
486
|
+
const errorLog = vi.spyOn(testLogger, 'error');
|
|
487
|
+
|
|
488
|
+
await expect(
|
|
489
|
+
bucketStorage
|
|
490
|
+
.createMongoCompactor({
|
|
491
|
+
maxOpId: 2n,
|
|
492
|
+
compactChunksOnly: true,
|
|
493
|
+
signal: abortController.signal,
|
|
494
|
+
logger: testLogger
|
|
495
|
+
})
|
|
496
|
+
.compact()
|
|
497
|
+
).rejects.toThrow();
|
|
498
|
+
|
|
499
|
+
const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
|
|
500
|
+
const completed = states.filter((state) => state.compacted_state != null);
|
|
501
|
+
const remaining = states.filter((state) => state.compacted_state == null);
|
|
502
|
+
expect(completed).toHaveLength(1);
|
|
503
|
+
expect(remaining).toHaveLength(2);
|
|
504
|
+
for (const state of remaining) {
|
|
505
|
+
expect(state.next_compact_check).toEqual(new Date(0));
|
|
506
|
+
expect(state.compact_lease).toBeUndefined();
|
|
507
|
+
}
|
|
508
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
|
|
512
|
+
const ops = [
|
|
513
|
+
makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
|
|
514
|
+
makeBucketDataDoc({ o: 3n, data: '{"id":"a"}' }),
|
|
515
|
+
makeBucketDataDoc({ o: 7n, data: '{"id":"b"}' })
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
const doc = serializeBucketData('test[]', ops);
|
|
519
|
+
|
|
520
|
+
expect(doc.ops!.map((op) => op.o)).toEqual([5n, 3n, 7n]);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('1. ops[] ordering - preserved after compaction', async () => {
|
|
524
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
525
|
+
const ops = [
|
|
526
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
527
|
+
makeOp(20, 'A', 'a2', ctx, sourceTableId),
|
|
528
|
+
makeOp(30, 'A', 'a3', ctx, sourceTableId)
|
|
529
|
+
];
|
|
530
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
531
|
+
await insertDocs(collection, [doc1]);
|
|
532
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
533
|
+
|
|
534
|
+
await compact(bucketStorage, 30n);
|
|
535
|
+
|
|
536
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
537
|
+
for (const doc of docs) {
|
|
538
|
+
for (let i = 1; i < doc.ops!.length; i++) {
|
|
539
|
+
expect(doc.ops![i].o).toBeGreaterThanOrEqual(doc.ops![i - 1].o);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('2. range metadata consistency - serializeBucketData fields', () => {
|
|
545
|
+
const ops = [
|
|
546
|
+
makeBucketDataDoc({ o: 3n, checksum: 10n, data: 'aaaa' }),
|
|
547
|
+
makeBucketDataDoc({ o: 5n, checksum: 20n, data: 'bbbbb' }),
|
|
548
|
+
makeBucketDataDoc({ o: 8n, checksum: 30n, data: 'cccccccc' })
|
|
549
|
+
];
|
|
550
|
+
|
|
551
|
+
const doc = serializeBucketData('test[]', ops);
|
|
552
|
+
|
|
553
|
+
expect(doc._id.o).toBe(8n);
|
|
554
|
+
expect(doc.min_op).toBe(3n);
|
|
555
|
+
expect(doc.count).toBe(3);
|
|
556
|
+
expect(doc.checksum).toBe(60n);
|
|
557
|
+
expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('2. has_clear_op is only stored when true', () => {
|
|
561
|
+
const putDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 1n })]);
|
|
562
|
+
const clearDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 2n, op: 'CLEAR', data: null })]);
|
|
563
|
+
|
|
564
|
+
expect(putDoc).not.toHaveProperty('has_clear_op');
|
|
565
|
+
expect(clearDoc.has_clear_op).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('2. range metadata consistency - after compaction', async () => {
|
|
569
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
570
|
+
const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
571
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
572
|
+
await insertDocs(collection, [doc1]);
|
|
573
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
|
|
574
|
+
|
|
575
|
+
await compact(bucketStorage, 20n);
|
|
576
|
+
|
|
577
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
578
|
+
for (const doc of docs) {
|
|
579
|
+
expect(doc._id.o).toBe(doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n));
|
|
580
|
+
expect(doc.min_op).toBe(doc.ops!.reduce((min, op) => (op.o < min ? op.o : min), doc.ops![0].o));
|
|
581
|
+
expect(doc.count).toBe(doc.ops!.length);
|
|
582
|
+
expect(doc.checksum).toBe(doc.ops!.reduce((sum, op) => sum + op.checksum, 0n));
|
|
583
|
+
expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test('4. no overlapping ranges - multiple documents', () => {
|
|
588
|
+
const opsA = [makeBucketDataDoc({ o: 1n }), makeBucketDataDoc({ o: 3n })];
|
|
589
|
+
const opsB = [makeBucketDataDoc({ o: 5n }), makeBucketDataDoc({ o: 8n })];
|
|
590
|
+
const opsC = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 15n })];
|
|
591
|
+
|
|
592
|
+
const docA = serializeBucketData('test[]', opsA);
|
|
593
|
+
const docB = serializeBucketData('test[]', opsB);
|
|
594
|
+
const docC = serializeBucketData('test[]', opsC);
|
|
595
|
+
|
|
596
|
+
const docs = [docA, docB, docC];
|
|
597
|
+
|
|
598
|
+
for (let i = 0; i < docs.length; i++) {
|
|
599
|
+
for (let j = i + 1; j < docs.length; j++) {
|
|
600
|
+
const a = docs[i];
|
|
601
|
+
const b = docs[j];
|
|
602
|
+
const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
|
|
603
|
+
expect(noOverlap).toBe(true);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test('4. no overlapping ranges - after compaction', async () => {
|
|
609
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
610
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
611
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
612
|
+
const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
|
|
613
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
614
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
615
|
+
const doc3 = serializeBucketData(BUCKET, ops3);
|
|
616
|
+
await insertDocs(collection, [doc1, doc2, doc3]);
|
|
617
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
|
|
618
|
+
|
|
619
|
+
await compact(bucketStorage, 60n);
|
|
620
|
+
|
|
621
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
622
|
+
for (let i = 0; i < docs.length; i++) {
|
|
623
|
+
for (let j = i + 1; j < docs.length; j++) {
|
|
624
|
+
const a = docs[i];
|
|
625
|
+
const b = docs[j];
|
|
626
|
+
const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
|
|
627
|
+
expect(noOverlap).toBe(true);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('6. compaction survivor integrity - superseded ops become MOVE tombstones', async () => {
|
|
633
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
634
|
+
const ops = [
|
|
635
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
636
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
637
|
+
makeOp(30, 'A', 'a2', ctx, sourceTableId)
|
|
638
|
+
];
|
|
639
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
640
|
+
await insertDocs(collection, [doc1]);
|
|
641
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
642
|
+
|
|
643
|
+
await compact(bucketStorage, 30n);
|
|
644
|
+
|
|
645
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
646
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
647
|
+
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
648
|
+
expect(moveOps.length).toBe(1);
|
|
649
|
+
expect(moveOps[0].o).toBe(10n);
|
|
650
|
+
expect(moveOps[0].checksum).toBe(ops[0].checksum);
|
|
651
|
+
expect(moveOps[0].data).toBeNull();
|
|
652
|
+
const putOps = allOps.filter((op) => op.op === 'PUT');
|
|
653
|
+
expect(putOps.length).toBe(2);
|
|
654
|
+
expect(docs[0].target_op).toBe(30n);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test('6. compaction survivor integrity - MOVE ops preserved', async () => {
|
|
658
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
659
|
+
const ops = [
|
|
660
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
661
|
+
{ ...makeOp(20, 'A', 'a2', ctx, sourceTableId), op: 'MOVE' as const, data: null },
|
|
662
|
+
makeOp(30, 'A', 'a3', ctx, sourceTableId)
|
|
663
|
+
];
|
|
664
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
665
|
+
await insertDocs(collection, [doc1]);
|
|
666
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
667
|
+
|
|
668
|
+
await compact(bucketStorage, 30n);
|
|
669
|
+
|
|
670
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
671
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
672
|
+
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
673
|
+
// Pre-existing MOVE@20 + new MOVE@10 are collapsed into CLEAR
|
|
674
|
+
expect(moveOps.length).toBe(0);
|
|
675
|
+
const clearOps = allOps.filter((op) => op.op === 'CLEAR');
|
|
676
|
+
expect(clearOps.length).toBe(1);
|
|
677
|
+
expect(clearOps[0].o).toBe(20n);
|
|
678
|
+
const putOps = allOps.filter((op) => op.op === 'PUT');
|
|
679
|
+
expect(putOps.length).toBe(1);
|
|
680
|
+
expect(putOps[0].o).toBe(30n);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('6. compaction survivor integrity - CLEAR ops preserved', async () => {
|
|
684
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
685
|
+
const ops = [
|
|
686
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
687
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
688
|
+
{ ...makeOp(25, 'A', '', ctx, sourceTableId), op: 'CLEAR' as const, data: null, row_id: undefined }
|
|
689
|
+
];
|
|
690
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
691
|
+
await insertDocs(collection, [doc1]);
|
|
692
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 25n);
|
|
693
|
+
|
|
694
|
+
await compact(bucketStorage, 25n);
|
|
695
|
+
|
|
696
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
697
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
698
|
+
const clearOps = allOps.filter((op) => op.op === 'CLEAR');
|
|
699
|
+
expect(clearOps.length).toBe(1);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test('7. superseded PUT becomes MOVE tombstone, REMOVE survives', async () => {
|
|
703
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
704
|
+
const ops = [
|
|
705
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
706
|
+
makeOp(20, 'A', 'a2', ctx, sourceTableId, { op: 'REMOVE' })
|
|
707
|
+
];
|
|
708
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
709
|
+
await insertDocs(collection, [doc1]);
|
|
710
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
|
|
711
|
+
|
|
712
|
+
await compact(bucketStorage, 20n);
|
|
713
|
+
|
|
714
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
715
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
716
|
+
// MOVE@10 + REMOVE@20 collapsed into CLEAR@20
|
|
717
|
+
const clearOps = allOps.filter((op) => op.op === 'CLEAR');
|
|
718
|
+
expect(clearOps.length).toBe(1);
|
|
719
|
+
expect(clearOps[0].o).toBe(20n);
|
|
720
|
+
expect(allOps.filter((op) => op.op === 'REMOVE').length).toBe(0);
|
|
721
|
+
expect(allOps.filter((op) => op.op === 'MOVE').length).toBe(0);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test('7. empty bucket compact is a no-op', async () => {
|
|
725
|
+
const { bucketStorage, collection } = await setupV3Storage();
|
|
726
|
+
|
|
727
|
+
await bucketStorage.compact({
|
|
728
|
+
clearBatchLimit: 200,
|
|
729
|
+
moveBatchLimit: 10,
|
|
730
|
+
moveBatchQueryLimit: 10,
|
|
731
|
+
minBucketChanges: 1,
|
|
732
|
+
minChangeRatio: 0,
|
|
733
|
+
maxOpId: 1n
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
737
|
+
expect(docs.length).toBe(0);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test('8. BSON limit safety - two large ops get split', () => {
|
|
741
|
+
const halfMB = 600_000;
|
|
742
|
+
const ops = [
|
|
743
|
+
makeBucketDataDoc({ o: 1n, data: 'a'.repeat(halfMB) }),
|
|
744
|
+
makeBucketDataDoc({ o: 2n, data: 'b'.repeat(halfMB) })
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
|
|
748
|
+
expect(chunks.length).toBe(2);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('8. BSON limit safety - single oversized op gets own chunk', () => {
|
|
752
|
+
const oversized = DEFAULT_MAX_DOC_SIZE_BYTES + 100_000;
|
|
753
|
+
const ops = [makeBucketDataDoc({ o: 1n, data: 'x'.repeat(oversized) })];
|
|
754
|
+
|
|
755
|
+
const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
|
|
756
|
+
expect(chunks.length).toBe(1);
|
|
757
|
+
expect(chunks[0]).toHaveLength(1);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test('9. serialization fidelity - null data preserved', () => {
|
|
761
|
+
const ops = [makeBucketDataDoc({ o: 1n, data: null })];
|
|
762
|
+
const doc = serializeBucketData('test[]', ops);
|
|
763
|
+
expect(doc.ops![0].data).toBeNull();
|
|
764
|
+
|
|
765
|
+
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
766
|
+
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
767
|
+
expect(deserialized[0].data).toBeNull();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('9. serialization fidelity - empty string data preserved', () => {
|
|
771
|
+
const ops = [makeBucketDataDoc({ o: 1n, data: '' })];
|
|
772
|
+
const doc = serializeBucketData('test[]', ops);
|
|
773
|
+
expect(doc.ops![0].data).toBe('');
|
|
774
|
+
|
|
775
|
+
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
776
|
+
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
777
|
+
expect(deserialized[0].data).toBe('');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('9. serialization fidelity - unicode characters preserved', () => {
|
|
781
|
+
const unicodeData = '{"name":"日本語テスト","emoji":"🎉"}';
|
|
782
|
+
const ops = [makeBucketDataDoc({ o: 1n, data: unicodeData })];
|
|
783
|
+
const doc = serializeBucketData('test[]', ops);
|
|
784
|
+
expect(doc.ops![0].data).toBe(unicodeData);
|
|
785
|
+
|
|
786
|
+
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
787
|
+
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
788
|
+
expect(deserialized[0].data).toBe(unicodeData);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test('10. document _id.o invariant - equals last ops[*].o (caller must sort)', () => {
|
|
792
|
+
const ops = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n }), makeBucketDataDoc({ o: 7n })];
|
|
793
|
+
|
|
794
|
+
const doc = serializeBucketData('test[]', ops);
|
|
795
|
+
expect(doc._id.o).toBe(7n);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test('10. document _id.o invariant - equals max when pre-sorted', () => {
|
|
799
|
+
const ops = [makeBucketDataDoc({ o: 3n }), makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n })];
|
|
800
|
+
|
|
801
|
+
const doc = serializeBucketData('test[]', ops);
|
|
802
|
+
const maxO = ops.reduce((max, op) => (op.o > max ? op.o : max), 0n);
|
|
803
|
+
expect(doc._id.o).toBe(maxO);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('10. document _id.o invariant - every document after compaction', async () => {
|
|
807
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
808
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
809
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
810
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
811
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
812
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
813
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
814
|
+
|
|
815
|
+
await compact(bucketStorage, 40n);
|
|
816
|
+
|
|
817
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
818
|
+
for (const doc of docs) {
|
|
819
|
+
const maxO = doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n);
|
|
820
|
+
expect(doc._id.o).toBe(maxO);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test('compaction with maxOpId filtering excludes a straddling document', async () => {
|
|
825
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
826
|
+
const ops = [
|
|
827
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
828
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
829
|
+
makeOp(30, 'C', 'c1', ctx, sourceTableId)
|
|
830
|
+
];
|
|
831
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
832
|
+
await insertDocs(collection, [doc1]);
|
|
833
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
834
|
+
|
|
835
|
+
await compact(bucketStorage, 15n);
|
|
836
|
+
|
|
837
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
838
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
839
|
+
|
|
840
|
+
// The document ends above maxOpId, so the database query excludes it as a unit.
|
|
841
|
+
expect(allOpsAfter.length).toBe(3);
|
|
842
|
+
const opsBelow = allOpsAfter.filter((op) => op.o <= 15n);
|
|
843
|
+
expect(opsBelow.length).toBe(1);
|
|
844
|
+
expect(opsBelow[0].op).toBe('PUT');
|
|
845
|
+
const opsAbove = allOpsAfter.filter((op) => op.o > 15n);
|
|
846
|
+
expect(opsAbove.length).toBe(2);
|
|
847
|
+
expect(opsAbove.every((op) => op.op === 'PUT')).toBe(true);
|
|
848
|
+
|
|
849
|
+
// Checksum preserved
|
|
850
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
851
|
+
expect(checksumAfter).toBe(Number(doc1.checksum));
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('checksum consistency - aggregation pipeline matches JavaScript addChecksums', async () => {
|
|
855
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
856
|
+
const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
857
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
858
|
+
await insertDocs(collection, [doc1]);
|
|
859
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
|
|
860
|
+
|
|
861
|
+
const allOps = ops;
|
|
862
|
+
let jsChecksum = 0;
|
|
863
|
+
for (const op of allOps) {
|
|
864
|
+
jsChecksum = addChecksums(jsChecksum, Number(op.checksum));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
868
|
+
const storedOps = docs.flatMap((d) => d.ops!);
|
|
869
|
+
let storedChecksum = 0;
|
|
870
|
+
for (const op of storedOps) {
|
|
871
|
+
storedChecksum = addChecksums(storedChecksum, Number(op.checksum));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
expect(storedChecksum).toBe(jsChecksum);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('V3 checksum pipeline straddling', () => {
|
|
879
|
+
const BUCKET = 'global[]';
|
|
880
|
+
|
|
881
|
+
async function setup() {
|
|
882
|
+
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
883
|
+
const syncRules = await factory.updateSyncRules(
|
|
884
|
+
updateSyncRulesFromYaml(
|
|
885
|
+
`
|
|
886
|
+
bucket_definitions:
|
|
887
|
+
global:
|
|
888
|
+
data: [SELECT id as id, description FROM items]
|
|
889
|
+
`,
|
|
890
|
+
{ storageVersion: 3 }
|
|
891
|
+
)
|
|
892
|
+
);
|
|
893
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
894
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
895
|
+
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
896
|
+
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
897
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
898
|
+
const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
|
|
899
|
+
const sourceTableId = new bson.ObjectId();
|
|
900
|
+
const ctx = {
|
|
901
|
+
replicationStreamId: bucketStorage.replicationStreamId,
|
|
902
|
+
definitionId,
|
|
903
|
+
bucket: BUCKET
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function checksumRequest(): storage.BucketChecksumRequest {
|
|
910
|
+
return {
|
|
911
|
+
bucket: BUCKET,
|
|
912
|
+
source: {
|
|
913
|
+
uniqueName: 'global',
|
|
914
|
+
bucketParameters: [],
|
|
915
|
+
getSourceTables: () => new Set(),
|
|
916
|
+
tableSyncsData: () => false,
|
|
917
|
+
evaluateRow: () => [],
|
|
918
|
+
inferSchema: () => ({ objects: {} }),
|
|
919
|
+
bucketQuery: () => ({ ast: {} as any, parameters: [] })
|
|
920
|
+
} as any
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
test('start straddle recalculates a cached checksum from the beginning', async () => {
|
|
925
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
926
|
+
|
|
927
|
+
// Single document with ops 10-60, min_op=10, _id.o=60
|
|
928
|
+
const ops = [
|
|
929
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
930
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
931
|
+
makeOp(30, 'C', 'c1', ctx, sourceTableId),
|
|
932
|
+
makeOp(40, 'D', 'd1', ctx, sourceTableId),
|
|
933
|
+
makeOp(50, 'E', 'e1', ctx, sourceTableId),
|
|
934
|
+
makeOp(60, 'F', 'f1', ctx, sourceTableId)
|
|
935
|
+
];
|
|
936
|
+
// Cache the first half before compaction changes the document boundary.
|
|
937
|
+
const firstDoc = serializeBucketData(BUCKET, ops.slice(0, 3));
|
|
938
|
+
await collection.insertOne(firstDoc);
|
|
939
|
+
|
|
940
|
+
const fullChecksum = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
941
|
+
await bucketStateCollection.insertOne({
|
|
942
|
+
_id: { d: definitionId, b: BUCKET },
|
|
943
|
+
last_op: 30n,
|
|
944
|
+
next_compact_check: undefined,
|
|
945
|
+
first_uncompacted_write: undefined,
|
|
946
|
+
bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const request = checksumRequest();
|
|
950
|
+
|
|
951
|
+
const cached = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
952
|
+
expect(cached.get(BUCKET)).toMatchObject({ count: 3 });
|
|
953
|
+
|
|
954
|
+
// Compaction rechunks the bucket into one document spanning the cached
|
|
955
|
+
// checkpoint. The requested endpoint is the end of the new document.
|
|
956
|
+
await collection.deleteMany({});
|
|
957
|
+
await collection.insertOne(serializeBucketData(BUCKET, ops, { targetOp: 60n }));
|
|
958
|
+
|
|
959
|
+
const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
|
|
960
|
+
const checksumResult = result.get(BUCKET)!;
|
|
961
|
+
|
|
962
|
+
// The cached checkpoint at 30 is inside the new document. The full result
|
|
963
|
+
// must replace it rather than add the document checksum to it.
|
|
964
|
+
expect(checksumResult.checksum).toBe(fullChecksum);
|
|
965
|
+
expect(checksumResult.count).toBe(6);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test('end straddle invalidates the old checkpoint and a later checkpoint succeeds', async () => {
|
|
969
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
970
|
+
|
|
971
|
+
// Document with ops 40-60, _id.o=60, min_op=40
|
|
972
|
+
const ops = [
|
|
973
|
+
makeOp(40, 'D', 'd1', ctx, sourceTableId),
|
|
974
|
+
makeOp(50, 'E', 'e1', ctx, sourceTableId),
|
|
975
|
+
makeOp(60, 'F', 'f1', ctx, sourceTableId)
|
|
976
|
+
];
|
|
977
|
+
const doc = serializeBucketData(BUCKET, ops, { targetOp: 60n });
|
|
978
|
+
await collection.insertMany([doc]);
|
|
979
|
+
|
|
980
|
+
const checksumAllOps = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
981
|
+
|
|
982
|
+
await bucketStateCollection.insertOne({
|
|
983
|
+
_id: { d: definitionId, b: BUCKET },
|
|
984
|
+
last_op: 0n,
|
|
985
|
+
next_compact_check: undefined,
|
|
986
|
+
first_uncompacted_write: undefined,
|
|
987
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
const request = checksumRequest();
|
|
991
|
+
|
|
992
|
+
await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
|
|
993
|
+
CheckpointChecksumInvalidatedError
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
|
|
997
|
+
expect(result.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: checksumAllOps, count: 3 });
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('end straddle without target_op also invalidates the old checkpoint', async () => {
|
|
1001
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
1002
|
+
const ops = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'F', 'f1', ctx, sourceTableId)];
|
|
1003
|
+
await collection.insertOne(serializeBucketData(BUCKET, ops));
|
|
1004
|
+
await bucketStateCollection.insertOne({
|
|
1005
|
+
_id: { d: definitionId, b: BUCKET },
|
|
1006
|
+
last_op: 0n,
|
|
1007
|
+
next_compact_check: undefined,
|
|
1008
|
+
first_uncompacted_write: undefined,
|
|
1009
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const request = checksumRequest();
|
|
1013
|
+
await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
|
|
1014
|
+
CheckpointChecksumInvalidatedError
|
|
1015
|
+
);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('has_clear_op replaces a cached checksum', async () => {
|
|
1019
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
1020
|
+
const beforeClear = makeOp(10, 'A', 'a1', ctx, sourceTableId);
|
|
1021
|
+
const clear = { ...makeOp(20, 'clear', '', ctx, sourceTableId), op: 'CLEAR' as const, checksum: 101n, data: null };
|
|
1022
|
+
const afterClear = makeOp(30, 'B', 'b1', ctx, sourceTableId);
|
|
1023
|
+
|
|
1024
|
+
await collection.insertOne(serializeBucketData(BUCKET, [beforeClear]));
|
|
1025
|
+
await bucketStateCollection.insertOne({
|
|
1026
|
+
_id: { d: definitionId, b: BUCKET },
|
|
1027
|
+
last_op: 10n,
|
|
1028
|
+
next_compact_check: undefined,
|
|
1029
|
+
first_uncompacted_write: undefined,
|
|
1030
|
+
bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const request = checksumRequest();
|
|
1034
|
+
const before = await bucketStorage.getChecksums(test_utils.testCheckpoint(10n), [request]);
|
|
1035
|
+
expect(before.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: 70, count: 1 });
|
|
1036
|
+
|
|
1037
|
+
// Compaction replaces everything preceding CLEAR, while the checksum cache
|
|
1038
|
+
// still contains the old checkpoint.
|
|
1039
|
+
await collection.deleteMany({});
|
|
1040
|
+
await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
|
|
1041
|
+
await bucketStateCollection.updateOne(
|
|
1042
|
+
{ _id: { d: definitionId, b: BUCKET } },
|
|
1043
|
+
{ $set: { last_op: 30n, 'bucket_stats.count': 2 } }
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
1047
|
+
expect(after.get(BUCKET)).toEqual({
|
|
1048
|
+
bucket: BUCKET,
|
|
1049
|
+
checksum: addChecksums(Number(clear.checksum), Number(afterClear.checksum)),
|
|
1050
|
+
count: 2
|
|
239
1051
|
});
|
|
240
1052
|
});
|
|
241
1053
|
});
|
|
242
1054
|
|
|
243
|
-
describe('
|
|
244
|
-
|
|
1055
|
+
describe('V3 compaction boundaries', () => {
|
|
1056
|
+
const BUCKET = 'global[]';
|
|
1057
|
+
|
|
1058
|
+
async function setupV3() {
|
|
1059
|
+
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
1060
|
+
const syncRules = await factory.updateSyncRules(
|
|
1061
|
+
updateSyncRulesFromYaml(
|
|
1062
|
+
`
|
|
1063
|
+
bucket_definitions:
|
|
1064
|
+
global:
|
|
1065
|
+
data: [SELECT id as id, description FROM items]
|
|
1066
|
+
`,
|
|
1067
|
+
{ storageVersion: 3 }
|
|
1068
|
+
)
|
|
1069
|
+
);
|
|
1070
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
1071
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
1072
|
+
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
1073
|
+
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
1074
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
1075
|
+
const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
|
|
1076
|
+
const sourceTableId = new bson.ObjectId();
|
|
1077
|
+
|
|
1078
|
+
const ctx = {
|
|
1079
|
+
replicationStreamId: bucketStorage.replicationStreamId,
|
|
1080
|
+
definitionId,
|
|
1081
|
+
bucket: BUCKET
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1088
|
+
await collection.insertMany(docs);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
|
|
1092
|
+
await bucketStateCollection.insertOne({
|
|
1093
|
+
_id: { d: definitionId, b: BUCKET },
|
|
1094
|
+
last_op: lastOp,
|
|
1095
|
+
next_compact_check: new Date(0),
|
|
1096
|
+
first_uncompacted_write: new Date(0),
|
|
1097
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
|
|
1102
|
+
await bucketStorage.compact({
|
|
1103
|
+
clearBatchLimit: 200,
|
|
1104
|
+
moveBatchLimit: 10,
|
|
1105
|
+
moveBatchQueryLimit: 10,
|
|
1106
|
+
minBucketChanges: 1,
|
|
1107
|
+
minChangeRatio: 0,
|
|
1108
|
+
maxOpId
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function readAllOps(collection: any): Promise<{ row_id: string; o: bigint; op: string }[]> {
|
|
1113
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1114
|
+
return docs.flatMap((d: any) =>
|
|
1115
|
+
d.ops!.map((op: any) => ({ row_id: op.row_id!, o: op.o, op: op.op, target_op: op.target_op ?? undefined }))
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function readAllDocs(collection: any): Promise<BucketDataDocumentV3[]> {
|
|
1120
|
+
return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
test('scheduled compaction claims only due buckets and clears completed full work', async () => {
|
|
1124
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1125
|
+
const docs = [
|
|
1126
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
|
|
1127
|
+
serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
|
|
1128
|
+
];
|
|
1129
|
+
await insertDocs(collection, docs);
|
|
1130
|
+
await bucketStateCollection.insertOne({
|
|
1131
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1132
|
+
last_op: 2n,
|
|
1133
|
+
next_compact_check: new Date(Date.now() + 60_000),
|
|
1134
|
+
first_uncompacted_write: new Date(Date.now() - 60_000),
|
|
1135
|
+
bucket_stats: {
|
|
1136
|
+
count: 2,
|
|
1137
|
+
bytes: BigInt(docs[0].size + docs[1].size),
|
|
1138
|
+
chunks: 2
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1143
|
+
expect(
|
|
1144
|
+
(await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
|
|
1145
|
+
).toBeUndefined();
|
|
1146
|
+
|
|
1147
|
+
await bucketStateCollection.updateOne(
|
|
1148
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1149
|
+
{ $set: { next_compact_check: new Date(Date.now() - 1) } }
|
|
1150
|
+
);
|
|
1151
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1152
|
+
|
|
1153
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1154
|
+
expect(state?.last_full_compact?.op_id).toBe(2n);
|
|
1155
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1156
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1157
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
|
|
1161
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1162
|
+
const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
|
|
1163
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
|
|
1164
|
+
await insertDocs(collection, [prefix, untouchedTail]);
|
|
1165
|
+
await bucketStateCollection.insertOne({
|
|
1166
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1167
|
+
last_op: 2n,
|
|
1168
|
+
next_compact_check: new Date(0),
|
|
1169
|
+
first_uncompacted_write: new Date(0),
|
|
1170
|
+
bucket_stats: {
|
|
1171
|
+
count: prefix.count + untouchedTail.count,
|
|
1172
|
+
bytes: BigInt(prefix.size + untouchedTail.size),
|
|
1173
|
+
chunks: 2
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1178
|
+
|
|
1179
|
+
const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1180
|
+
expect(partialState?.last_full_compact).toMatchObject({
|
|
1181
|
+
op_id: 1n,
|
|
1182
|
+
count: prefix.count,
|
|
1183
|
+
puts: 1
|
|
1184
|
+
});
|
|
1185
|
+
expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
|
|
1186
|
+
expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
|
|
1187
|
+
expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
|
|
1188
|
+
partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
const lastFullCompactAt = partialState!.last_full_compact!.at;
|
|
1192
|
+
const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
|
|
1193
|
+
await bucketStateCollection.updateOne(
|
|
1194
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1195
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
await bucketStorage.compact({ maxOpId: 2n });
|
|
1199
|
+
|
|
1200
|
+
const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1201
|
+
expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1202
|
+
expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
|
|
1203
|
+
expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
1204
|
+
|
|
1205
|
+
// Even after the scheduling window has elapsed, a run with the old cap
|
|
1206
|
+
// must not scan and record the same full-compaction prefix again.
|
|
1207
|
+
await bucketStateCollection.updateOne(
|
|
1208
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1209
|
+
{ $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1213
|
+
|
|
1214
|
+
const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1215
|
+
expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1216
|
+
expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
|
|
1217
|
+
expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
|
|
1221
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1222
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1223
|
+
await insertDocs(collection, [document]);
|
|
1224
|
+
await bucketStateCollection.insertOne({
|
|
1225
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1226
|
+
last_op: 1n,
|
|
1227
|
+
next_compact_check: undefined,
|
|
1228
|
+
first_uncompacted_write: undefined,
|
|
1229
|
+
compacted_state: {
|
|
1230
|
+
op_id: 1n,
|
|
1231
|
+
checksum: document.checksum,
|
|
1232
|
+
count: document.count,
|
|
1233
|
+
bytes: BigInt(document.size),
|
|
1234
|
+
chunks: 1,
|
|
1235
|
+
at: new Date()
|
|
1236
|
+
},
|
|
1237
|
+
last_full_compact: {
|
|
1238
|
+
op_id: 1n,
|
|
1239
|
+
count: document.count,
|
|
1240
|
+
puts: 1,
|
|
1241
|
+
at: new Date()
|
|
1242
|
+
},
|
|
1243
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
|
|
1247
|
+
|
|
1248
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1249
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1250
|
+
expect(state?.next_compact_check).toBeNull();
|
|
1251
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('concurrent scheduled compactors lease a bucket to one worker', async () => {
|
|
1255
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1256
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1257
|
+
await insertDocs(collection, [document]);
|
|
1258
|
+
await bucketStateCollection.insertOne({
|
|
1259
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1260
|
+
last_op: 1n,
|
|
1261
|
+
next_compact_check: new Date(Date.now() - 1),
|
|
1262
|
+
first_uncompacted_write: new Date(0),
|
|
1263
|
+
bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
|
|
1267
|
+
|
|
1268
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1269
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1270
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test('1. superseded ops become MOVE tombstones', async () => {
|
|
1274
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1275
|
+
// Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
|
|
1276
|
+
const ops = [
|
|
1277
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
1278
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
1279
|
+
makeOp(30, 'A', 'a2', ctx, sourceTableId)
|
|
1280
|
+
];
|
|
1281
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
1282
|
+
await insertDocs(collection, [doc1]);
|
|
1283
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
1284
|
+
|
|
1285
|
+
await compact(bucketStorage, 30n);
|
|
1286
|
+
|
|
1287
|
+
const surviving = await readAllOps(collection);
|
|
1288
|
+
expect(surviving).toHaveLength(3);
|
|
1289
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1290
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1291
|
+
expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
test('2. first op in document superseded becomes MOVE tombstone', async () => {
|
|
1295
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1296
|
+
// Doc1: [A@10, B@20, C@30], Doc2: [A@40] — A@10 superseded by A@40
|
|
1297
|
+
const ops1 = [
|
|
1298
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
1299
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
1300
|
+
makeOp(30, 'C', 'c1', ctx, sourceTableId)
|
|
1301
|
+
];
|
|
1302
|
+
const ops2 = [makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
1303
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1304
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1305
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1306
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
1307
|
+
|
|
1308
|
+
await compact(bucketStorage, 40n);
|
|
1309
|
+
|
|
1310
|
+
const surviving = await readAllOps(collection);
|
|
1311
|
+
expect(surviving).toHaveLength(4);
|
|
1312
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1313
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1314
|
+
expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
|
|
1315
|
+
expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test('3. last op in document superseded becomes MOVE tombstone', async () => {
|
|
1319
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1320
|
+
// Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded by A@40
|
|
1321
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
1322
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
1323
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1324
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1325
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1326
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
1327
|
+
|
|
1328
|
+
await compact(bucketStorage, 40n);
|
|
1329
|
+
|
|
1330
|
+
const surviving = await readAllOps(collection);
|
|
1331
|
+
expect(surviving).toHaveLength(4);
|
|
1332
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1333
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1334
|
+
expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
|
|
1335
|
+
expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test('4. cascading superseded ops become MOVE tombstones', async () => {
|
|
1339
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1340
|
+
// Doc1: [A@10, A@20] — A@10 superseded by A@20; A@20 superseded by A@30 REMOVE
|
|
1341
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'A', 'a2', ctx, sourceTableId)];
|
|
1342
|
+
const ops2 = [makeOp(30, 'A', 'a3', ctx, sourceTableId, { op: 'REMOVE' })];
|
|
1343
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1344
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1345
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1346
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
1347
|
+
|
|
1348
|
+
await compact(bucketStorage, 30n);
|
|
1349
|
+
|
|
1350
|
+
const surviving = await readAllOps(collection);
|
|
1351
|
+
expect(surviving).toHaveLength(1);
|
|
1352
|
+
expect(surviving[0]).toMatchObject({ op: 'CLEAR', o: 30n });
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
test('5. one surviving PUT per document plus MOVE tombstones', async () => {
|
|
1356
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1357
|
+
// Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded, becomes MOVE
|
|
1358
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
1359
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
1360
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1361
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1362
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1363
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
1364
|
+
|
|
1365
|
+
await compact(bucketStorage, 40n);
|
|
1366
|
+
|
|
1367
|
+
const surviving = await readAllOps(collection);
|
|
1368
|
+
expect(surviving).toHaveLength(4);
|
|
1369
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1370
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1371
|
+
expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
|
|
1372
|
+
expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test('6. multiple superseded ops from different documents become MOVE tombstones', async () => {
|
|
1376
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1377
|
+
// 3 small docs, each with 2 ops, A@10 superseded by A@40
|
|
1378
|
+
// Doc1: [A@10, B@20] — A@10 becomes MOVE
|
|
1379
|
+
// Doc2: [C@30, A@40] — C@30 survives (A@40 is latest A)
|
|
1380
|
+
// Doc3: [D@50, E@60] — D@50, E@60 survive
|
|
1381
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
1382
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
1383
|
+
const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
|
|
1384
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1385
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1386
|
+
const doc3 = serializeBucketData(BUCKET, ops3);
|
|
1387
|
+
await insertDocs(collection, [doc1, doc2, doc3]);
|
|
1388
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
|
|
1389
|
+
|
|
1390
|
+
await compact(bucketStorage, 60n);
|
|
1391
|
+
|
|
1392
|
+
const surviving = await readAllOps(collection);
|
|
1393
|
+
expect(surviving).toHaveLength(6);
|
|
1394
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1395
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1396
|
+
expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
|
|
1397
|
+
expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
|
|
1398
|
+
expect(surviving[4]).toMatchObject({ row_id: 'D', o: 50n, op: 'PUT' });
|
|
1399
|
+
expect(surviving[5]).toMatchObject({ row_id: 'E', o: 60n, op: 'PUT' });
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test('7. superseded op at document boundary becomes MOVE tombstone', async () => {
|
|
1403
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1404
|
+
// Doc1: [A@10, B@20, C@30], Doc2: [D@40, A@50] — A@10 superseded by A@50
|
|
1405
|
+
const ops1 = [
|
|
1406
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
1407
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
1408
|
+
makeOp(30, 'C', 'c1', ctx, sourceTableId)
|
|
1409
|
+
];
|
|
1410
|
+
const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
|
|
1411
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1412
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1413
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1414
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
|
|
1415
|
+
|
|
1416
|
+
await compact(bucketStorage, 50n);
|
|
1417
|
+
|
|
1418
|
+
const surviving = await readAllOps(collection);
|
|
1419
|
+
expect(surviving).toHaveLength(5);
|
|
1420
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1421
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1422
|
+
expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
|
|
1423
|
+
expect(surviving[3]).toMatchObject({ row_id: 'D', o: 40n, op: 'PUT' });
|
|
1424
|
+
expect(surviving[4]).toMatchObject({ row_id: 'A', o: 50n, op: 'PUT' });
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test('8. same row_id ops spanning document boundary produces MOVE tombstone', async () => {
|
|
1428
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1429
|
+
// Doc1: [A@10, B@20], Doc2: [A@30, C@40] — A@10 superseded by A@30
|
|
1430
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
1431
|
+
const ops2 = [makeOp(30, 'A', 'a2', ctx, sourceTableId), makeOp(40, 'C', 'c1', ctx, sourceTableId)];
|
|
1432
|
+
const doc1 = serializeBucketData(BUCKET, ops1);
|
|
1433
|
+
const doc2 = serializeBucketData(BUCKET, ops2);
|
|
1434
|
+
await insertDocs(collection, [doc1, doc2]);
|
|
1435
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
1436
|
+
|
|
1437
|
+
await compact(bucketStorage, 40n);
|
|
1438
|
+
|
|
1439
|
+
const surviving = await readAllOps(collection);
|
|
1440
|
+
expect(surviving).toHaveLength(4);
|
|
1441
|
+
expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
|
|
1442
|
+
expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
|
|
1443
|
+
expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
|
|
1444
|
+
expect(surviving[3]).toMatchObject({ row_id: 'C', o: 40n, op: 'PUT' });
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
describe('V3 MOVE tombstone properties', () => {
|
|
1449
|
+
const BUCKET = 'global[]';
|
|
1450
|
+
|
|
1451
|
+
async function setupV3() {
|
|
1452
|
+
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
1453
|
+
const syncRules = await factory.updateSyncRules(
|
|
1454
|
+
updateSyncRulesFromYaml(
|
|
1455
|
+
`
|
|
1456
|
+
bucket_definitions:
|
|
1457
|
+
global:
|
|
1458
|
+
data: [SELECT id as id, description FROM items]
|
|
1459
|
+
`,
|
|
1460
|
+
{ storageVersion: 3 }
|
|
1461
|
+
)
|
|
1462
|
+
);
|
|
1463
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
1464
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
1465
|
+
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
1466
|
+
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
1467
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
1468
|
+
const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
|
|
1469
|
+
const sourceTableId = new bson.ObjectId();
|
|
1470
|
+
|
|
1471
|
+
const ctx = {
|
|
1472
|
+
replicationStreamId: bucketStorage.replicationStreamId,
|
|
1473
|
+
definitionId,
|
|
1474
|
+
bucket: BUCKET
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1481
|
+
await collection.insertMany(docs);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
|
|
1485
|
+
await bucketStateCollection.insertOne({
|
|
1486
|
+
_id: { d: definitionId, b: BUCKET },
|
|
1487
|
+
last_op: lastOp,
|
|
1488
|
+
next_compact_check: new Date(0),
|
|
1489
|
+
first_uncompacted_write: new Date(0),
|
|
1490
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
|
|
1495
|
+
await bucketStorage.compact({
|
|
1496
|
+
clearBatchLimit: 200,
|
|
1497
|
+
moveBatchLimit: 10,
|
|
1498
|
+
moveBatchQueryLimit: 10,
|
|
1499
|
+
minBucketChanges: 1,
|
|
1500
|
+
minChangeRatio: 0,
|
|
1501
|
+
maxOpId
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
test('checksum preserved across compaction with superseded ops', async () => {
|
|
1506
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1507
|
+
const ops = [
|
|
1508
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
1509
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
1510
|
+
makeOp(30, 'A', 'a2', ctx, sourceTableId),
|
|
1511
|
+
makeOp(40, 'C', 'c1', ctx, sourceTableId),
|
|
1512
|
+
makeOp(50, 'B', 'b2', ctx, sourceTableId)
|
|
1513
|
+
];
|
|
1514
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
1515
|
+
await insertDocs(collection, [doc1]);
|
|
1516
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
|
|
1517
|
+
|
|
1518
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
1519
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1520
|
+
|
|
1521
|
+
await compact(bucketStorage, 50n);
|
|
1522
|
+
|
|
1523
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1524
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1525
|
+
|
|
1526
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
1527
|
+
|
|
1528
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
1529
|
+
// Two MOVEs collapsed into one CLEAR
|
|
1530
|
+
expect(allOpsAfter.length).toBe(4);
|
|
1531
|
+
const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
|
|
1532
|
+
expect(clearOps.length).toBe(1);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test('checksum preserved across compaction with multiple documents', async () => {
|
|
1536
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1537
|
+
const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
|
|
1538
|
+
const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
|
|
1539
|
+
const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'B', 'b2', ctx, sourceTableId)];
|
|
1540
|
+
await insertDocs(collection, [
|
|
1541
|
+
serializeBucketData(BUCKET, ops1),
|
|
1542
|
+
serializeBucketData(BUCKET, ops2),
|
|
1543
|
+
serializeBucketData(BUCKET, ops3)
|
|
1544
|
+
]);
|
|
1545
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
|
|
1546
|
+
|
|
1547
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
1548
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1549
|
+
|
|
1550
|
+
await compact(bucketStorage, 60n);
|
|
1551
|
+
|
|
1552
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1553
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1554
|
+
|
|
1555
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
1556
|
+
|
|
1557
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
1558
|
+
// Two MOVEs collapsed into one CLEAR
|
|
1559
|
+
expect(allOpsAfter.length).toBe(5);
|
|
1560
|
+
const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
|
|
1561
|
+
expect(clearOps.length).toBe(1);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test('tombstones have null data and pack densely after rechunking', async () => {
|
|
1565
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1566
|
+
const ops = [
|
|
1567
|
+
makeOp(10, 'A', 'x'.repeat(500_000), ctx, sourceTableId),
|
|
1568
|
+
makeOp(20, 'B', 'y'.repeat(500_000), ctx, sourceTableId),
|
|
1569
|
+
makeOp(30, 'A', 'z'.repeat(500_000), ctx, sourceTableId)
|
|
1570
|
+
];
|
|
1571
|
+
const doc1 = serializeBucketData(BUCKET, ops);
|
|
1572
|
+
await insertDocs(collection, [doc1]);
|
|
1573
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
1574
|
+
|
|
1575
|
+
await compact(bucketStorage, 30n);
|
|
1576
|
+
|
|
1577
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1578
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
1579
|
+
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
1580
|
+
expect(moveOps.length).toBe(1);
|
|
1581
|
+
expect(moveOps[0].o).toBe(10n);
|
|
1582
|
+
expect(moveOps[0].data).toBeNull();
|
|
1583
|
+
|
|
1584
|
+
const putOps = allOps.filter((op) => op.op === 'PUT');
|
|
1585
|
+
expect(putOps.length).toBe(2);
|
|
1586
|
+
expect(putOps.every((op) => op.data != null)).toBe(true);
|
|
1587
|
+
|
|
1588
|
+
expect(docs.length).toBe(1);
|
|
1589
|
+
// Size calculation is not exact - we just check for a range
|
|
1590
|
+
expect(docs[0].size).toBeGreaterThan(1_000_000);
|
|
1591
|
+
expect(docs[0].size).toBeLessThan(1_001_000);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test('tombstones and survivors end up in same document after rechunking', async () => {
|
|
1595
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1596
|
+
const ops1 = [
|
|
1597
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
1598
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
1599
|
+
makeOp(30, 'C', 'c1', ctx, sourceTableId)
|
|
1600
|
+
];
|
|
1601
|
+
const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
|
|
1602
|
+
await insertDocs(collection, [serializeBucketData(BUCKET, ops1), serializeBucketData(BUCKET, ops2)]);
|
|
1603
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
|
|
1604
|
+
|
|
1605
|
+
const checksumBefore = (await collection.find({ '_id.b': BUCKET }).toArray()).reduce(
|
|
1606
|
+
(sum, d) => addChecksums(sum, Number(d.checksum)),
|
|
1607
|
+
0
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
await compact(bucketStorage, 50n);
|
|
1611
|
+
|
|
1612
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1613
|
+
const checksumAfter = docs.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1614
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
1615
|
+
|
|
1616
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
1617
|
+
expect(allOps.length).toBe(5);
|
|
1618
|
+
const moveOp = allOps.find((op) => op.op === 'MOVE');
|
|
1619
|
+
expect(moveOp).toMatchObject({ o: 10n, data: null });
|
|
1620
|
+
|
|
1621
|
+
expect(docs.length).toBe(1);
|
|
1622
|
+
|
|
1623
|
+
const moveOpsInDoc = docs[0].ops!.filter((op) => op.op === 'MOVE');
|
|
1624
|
+
const putOpsInDoc = docs[0].ops!.filter((op) => op.op === 'PUT');
|
|
1625
|
+
expect(moveOpsInDoc.length).toBe(1);
|
|
1626
|
+
expect(putOpsInDoc.length).toBe(4);
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* Streaming compactor tests
|
|
1632
|
+
*
|
|
1633
|
+
* These tests exercise the streaming compactor's batched behavior:
|
|
1634
|
+
* reverse-order batched reads, cross-batch seen map dedup with memory
|
|
1635
|
+
* bounding, scoped deletes via $in, and byte-based batch cutting.
|
|
1636
|
+
*/
|
|
1637
|
+
describe('Streaming compactor', () => {
|
|
1638
|
+
const BUCKET = 'global[]';
|
|
1639
|
+
|
|
1640
|
+
async function setupV3() {
|
|
1641
|
+
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
1642
|
+
const syncRules = await factory.updateSyncRules(
|
|
1643
|
+
updateSyncRulesFromYaml(
|
|
1644
|
+
`
|
|
1645
|
+
bucket_definitions:
|
|
1646
|
+
global:
|
|
1647
|
+
data: [SELECT id as id, description FROM items]
|
|
1648
|
+
`,
|
|
1649
|
+
{ storageVersion: 3 }
|
|
1650
|
+
)
|
|
1651
|
+
);
|
|
1652
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
1653
|
+
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
1654
|
+
const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
|
|
1655
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
1656
|
+
const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
|
|
1657
|
+
const sourceTableId = new bson.ObjectId();
|
|
1658
|
+
|
|
1659
|
+
const ctx = {
|
|
1660
|
+
replicationStreamId: bucketStorage.replicationStreamId,
|
|
1661
|
+
definitionId,
|
|
1662
|
+
bucket: BUCKET
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1669
|
+
await collection.insertMany(docs);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
|
|
1673
|
+
await bucketStateCollection.insertOne({
|
|
1674
|
+
_id: { d: definitionId, b: BUCKET },
|
|
1675
|
+
last_op: lastOp,
|
|
1676
|
+
next_compact_check: new Date(0),
|
|
1677
|
+
first_uncompacted_write: new Date(0),
|
|
1678
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
async function readAllOps(
|
|
1683
|
+
collection: any
|
|
1684
|
+
): Promise<{ row_id: string | undefined; o: bigint; op: string; target_op: bigint | undefined }[]> {
|
|
1685
|
+
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1686
|
+
return docs.flatMap((d: any) =>
|
|
1687
|
+
d.ops!.map((op: any) => ({
|
|
1688
|
+
row_id: op.row_id ?? undefined,
|
|
1689
|
+
o: op.o,
|
|
1690
|
+
op: op.op,
|
|
1691
|
+
target_op: op.target_op ?? undefined
|
|
1692
|
+
}))
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async function setupCompactedTail() {
|
|
1697
|
+
const setup = await setupV3();
|
|
1698
|
+
const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
|
|
1699
|
+
const documents = [
|
|
1700
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1701
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1702
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1703
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1704
|
+
];
|
|
1705
|
+
await insertDocs(collection, documents);
|
|
1706
|
+
await bucketStateCollection.insertOne({
|
|
1707
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1708
|
+
last_op: 4n,
|
|
1709
|
+
next_compact_check: new Date(0),
|
|
1710
|
+
first_uncompacted_write: new Date(0),
|
|
1711
|
+
compacted_state: {
|
|
1712
|
+
op_id: 2n,
|
|
1713
|
+
checksum: documents[0].checksum + documents[1].checksum,
|
|
1714
|
+
count: documents[0].count + documents[1].count,
|
|
1715
|
+
bytes: BigInt(documents[0].size + documents[1].size),
|
|
1716
|
+
chunks: 2,
|
|
1717
|
+
at: new Date(0)
|
|
1718
|
+
},
|
|
1719
|
+
bucket_stats: {
|
|
1720
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1721
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1722
|
+
chunks: documents.length
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
return setup;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
|
|
1729
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1730
|
+
expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
|
|
1731
|
+
1n,
|
|
1732
|
+
2n,
|
|
1733
|
+
3n,
|
|
1734
|
+
4n
|
|
1735
|
+
]);
|
|
1736
|
+
|
|
1737
|
+
const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
|
|
1738
|
+
const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
|
|
1739
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1740
|
+
op_id: 4n,
|
|
1741
|
+
checksum: 70n,
|
|
1742
|
+
count: 4,
|
|
1743
|
+
bytes,
|
|
1744
|
+
chunks: documents.length
|
|
1745
|
+
});
|
|
1746
|
+
expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
|
|
1750
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1751
|
+
await insertDocs(collection, [
|
|
1752
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1753
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1754
|
+
]);
|
|
1755
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
|
|
1756
|
+
|
|
1757
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
1758
|
+
|
|
1759
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1760
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1761
|
+
expect(documents).toHaveLength(1);
|
|
1762
|
+
expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
|
|
1763
|
+
|
|
1764
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1765
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1766
|
+
op_id: 4n,
|
|
1767
|
+
count: 4,
|
|
1768
|
+
checksum: 70n
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
|
|
1773
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1774
|
+
const operations = [
|
|
1775
|
+
makeOp(1, 'A', 'a', ctx, sourceTableId),
|
|
1776
|
+
makeOp(2, 'B', 'b', ctx, sourceTableId),
|
|
1777
|
+
makeOp(3, 'C', 'c', ctx, sourceTableId),
|
|
1778
|
+
makeOp(4, 'D', 'd', ctx, sourceTableId)
|
|
1779
|
+
];
|
|
1780
|
+
const originalDocuments = [
|
|
1781
|
+
serializeBucketData(BUCKET, operations.slice(0, 2)),
|
|
1782
|
+
serializeBucketData(BUCKET, operations.slice(2))
|
|
1783
|
+
];
|
|
1784
|
+
const committedMerge = serializeBucketData(BUCKET, operations);
|
|
1785
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
1786
|
+
await insertDocs(collection, [committedMerge, untouchedTail]);
|
|
1787
|
+
await bucketStateCollection.insertOne({
|
|
1788
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1789
|
+
last_op: 5n,
|
|
1790
|
+
next_compact_check: new Date(0),
|
|
1791
|
+
first_uncompacted_write: new Date(0),
|
|
1792
|
+
bucket_stats: {
|
|
1793
|
+
count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
|
|
1794
|
+
bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
|
|
1795
|
+
chunks: originalDocuments.length + 1
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
1800
|
+
|
|
1801
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1802
|
+
const expectedStats = {
|
|
1803
|
+
count: committedMerge.count + untouchedTail.count,
|
|
1804
|
+
bytes: BigInt(committedMerge.size + untouchedTail.size),
|
|
1805
|
+
chunks: 2
|
|
1806
|
+
};
|
|
1807
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1808
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
1809
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1810
|
+
op_id: 4n,
|
|
1811
|
+
checksum: committedMerge.checksum,
|
|
1812
|
+
count: committedMerge.count,
|
|
1813
|
+
bytes: BigInt(committedMerge.size),
|
|
1814
|
+
chunks: 1
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test('later full compaction claims fresh state after a committed replacement', async () => {
|
|
1819
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1820
|
+
const documents = [
|
|
1821
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1822
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1823
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1824
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1825
|
+
];
|
|
1826
|
+
await insertDocs(collection, documents);
|
|
1827
|
+
await bucketStateCollection.insertOne({
|
|
1828
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1829
|
+
last_op: 4n,
|
|
1830
|
+
next_compact_check: new Date(0),
|
|
1831
|
+
first_uncompacted_write: new Date(0),
|
|
1832
|
+
bucket_stats: {
|
|
1833
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1834
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1835
|
+
chunks: documents.length
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
1840
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
|
|
1841
|
+
const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
|
|
1842
|
+
let injectedFailure = false;
|
|
1843
|
+
vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
|
|
1844
|
+
const result = await originalFlush(...args);
|
|
1845
|
+
if (!injectedFailure) {
|
|
1846
|
+
injectedFailure = true;
|
|
1847
|
+
await insertDocs(collection, [concurrentDocument]);
|
|
1848
|
+
await bucketStateCollection.updateOne(
|
|
1849
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1850
|
+
{
|
|
1851
|
+
$set: { last_op: 5n },
|
|
1852
|
+
$inc: {
|
|
1853
|
+
'bucket_stats.count': concurrentDocument.count,
|
|
1854
|
+
'bucket_stats.bytes': BigInt(concurrentDocument.size),
|
|
1855
|
+
'bucket_stats.chunks': 1
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
);
|
|
1859
|
+
throw new ObjectStorageError('failure after committed full-compaction replacement', {
|
|
1860
|
+
cause: new Error('socket reset'),
|
|
1861
|
+
retryable: true
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
return result;
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
|
|
1868
|
+
|
|
1869
|
+
expect(injectedFailure).toBe(true);
|
|
1870
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1871
|
+
expect(interruptedState?.last_op).toBe(5n);
|
|
1872
|
+
expect(interruptedState?.compacted_state).toBeUndefined();
|
|
1873
|
+
expect(interruptedState?.compact_lease).toBeUndefined();
|
|
1874
|
+
|
|
1875
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
|
|
1876
|
+
1
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
1880
|
+
expect(currentDocuments).toHaveLength(1);
|
|
1881
|
+
const expectedStats = {
|
|
1882
|
+
count: currentDocuments.reduce((total, document) => total + document.count, 0),
|
|
1883
|
+
bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
|
|
1884
|
+
chunks: currentDocuments.length
|
|
1885
|
+
};
|
|
1886
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1887
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
1888
|
+
expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
|
|
1889
|
+
expect(state?.last_full_compact?.op_id).toBe(5n);
|
|
1890
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1891
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
test('chunk compaction treats a missing cached op as a resume hint', async () => {
|
|
1895
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1896
|
+
const documents = [
|
|
1897
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1898
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1899
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1900
|
+
];
|
|
1901
|
+
await insertDocs(collection, documents);
|
|
1902
|
+
await bucketStateCollection.insertOne({
|
|
1903
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1904
|
+
last_op: 4n,
|
|
1905
|
+
next_compact_check: new Date(0),
|
|
1906
|
+
first_uncompacted_write: new Date(0),
|
|
1907
|
+
compacted_state: {
|
|
1908
|
+
op_id: 2n,
|
|
1909
|
+
checksum: 21n,
|
|
1910
|
+
count: 2,
|
|
1911
|
+
bytes: 100n,
|
|
1912
|
+
chunks: 2,
|
|
1913
|
+
at: new Date(0)
|
|
1914
|
+
},
|
|
1915
|
+
bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
|
|
1919
|
+
1
|
|
1920
|
+
);
|
|
1921
|
+
|
|
1922
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1923
|
+
expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
|
|
1924
|
+
const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
|
|
1925
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1926
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1927
|
+
op_id: 4n,
|
|
1928
|
+
checksum: 56n,
|
|
1929
|
+
count: 3,
|
|
1930
|
+
bytes,
|
|
1931
|
+
chunks: currentDocuments.length
|
|
1932
|
+
});
|
|
1933
|
+
expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
test('later chunk compaction rebuilds state after a committed partial merge', async () => {
|
|
1937
|
+
const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
|
|
1938
|
+
const failedCompactor = bucketStorage.createMongoCompactor({
|
|
1939
|
+
maxOpId: 4n,
|
|
1940
|
+
compactChunksOnly: true
|
|
1941
|
+
});
|
|
1942
|
+
const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
|
|
1943
|
+
const flushSpy = vi
|
|
1944
|
+
.spyOn(failedCompactor as any, 'flushCompactionGroup')
|
|
1945
|
+
.mockImplementation(async (...args: any[]) => {
|
|
1946
|
+
const result = await originalFlush(...args);
|
|
1947
|
+
throw new Error('failure after committed chunk merge');
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
await expect(failedCompactor.compact()).resolves.toBe(0);
|
|
1951
|
+
flushSpy.mockRestore();
|
|
1952
|
+
|
|
1953
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1954
|
+
expect(interruptedState?.compacted_state?.op_id).toBe(2n);
|
|
1955
|
+
expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
|
|
1956
|
+
await bucketStateCollection.updateOne(
|
|
1957
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1958
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1959
|
+
);
|
|
1960
|
+
|
|
1961
|
+
const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
|
|
1962
|
+
|
|
1963
|
+
expect(result).toBe(1);
|
|
1964
|
+
await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
|
|
1968
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1969
|
+
|
|
1970
|
+
// 6 documents, 3-4 ops each, 3 rows with 2 versions spread across documents.
|
|
1971
|
+
// Rows A, B, C each have old+new versions:
|
|
1972
|
+
// A: new@24 (Doc1), old@10 (Doc4)
|
|
1973
|
+
// B: new@20 (Doc2), old@6 (Doc5)
|
|
1974
|
+
// C: new@16 (Doc3), old@2 (Doc6)
|
|
1975
|
+
// With moveBatchQueryLimit=2, the streaming compactor must process
|
|
1976
|
+
// 2 documents per batch (3 batches total) and still produce correct output.
|
|
1977
|
+
const doc1 = serializeBucketData(BUCKET, [
|
|
1978
|
+
makeOp(21, 'D', 'd1', ctx, sourceTableId),
|
|
1979
|
+
makeOp(22, 'E', 'e1', ctx, sourceTableId),
|
|
1980
|
+
makeOp(23, 'F', 'f1', ctx, sourceTableId),
|
|
1981
|
+
makeOp(24, 'A', 'a_new', ctx, sourceTableId)
|
|
1982
|
+
]);
|
|
1983
|
+
const doc2 = serializeBucketData(BUCKET, [
|
|
1984
|
+
makeOp(18, 'G', 'g1', ctx, sourceTableId),
|
|
1985
|
+
makeOp(19, 'H', 'h1', ctx, sourceTableId),
|
|
1986
|
+
makeOp(20, 'B', 'b_new', ctx, sourceTableId)
|
|
1987
|
+
]);
|
|
1988
|
+
const doc3 = serializeBucketData(BUCKET, [
|
|
1989
|
+
makeOp(14, 'I', 'i1', ctx, sourceTableId),
|
|
1990
|
+
makeOp(15, 'J', 'j1', ctx, sourceTableId),
|
|
1991
|
+
makeOp(16, 'C', 'c_new', ctx, sourceTableId)
|
|
1992
|
+
]);
|
|
1993
|
+
const doc4 = serializeBucketData(BUCKET, [
|
|
1994
|
+
makeOp(10, 'A', 'a_old', ctx, sourceTableId),
|
|
1995
|
+
makeOp(11, 'K', 'k1', ctx, sourceTableId),
|
|
1996
|
+
makeOp(12, 'L', 'l1', ctx, sourceTableId)
|
|
1997
|
+
]);
|
|
1998
|
+
const doc5 = serializeBucketData(BUCKET, [
|
|
1999
|
+
makeOp(6, 'B', 'b_old', ctx, sourceTableId),
|
|
2000
|
+
makeOp(7, 'M', 'm1', ctx, sourceTableId),
|
|
2001
|
+
makeOp(8, 'N', 'n1', ctx, sourceTableId)
|
|
2002
|
+
]);
|
|
2003
|
+
const doc6 = serializeBucketData(BUCKET, [
|
|
2004
|
+
makeOp(2, 'C', 'c_old', ctx, sourceTableId),
|
|
2005
|
+
makeOp(3, 'O', 'o1', ctx, sourceTableId),
|
|
2006
|
+
makeOp(4, 'P', 'p1', ctx, sourceTableId)
|
|
2007
|
+
]);
|
|
2008
|
+
await insertDocs(collection, [doc1, doc2, doc3, doc4, doc5, doc6]);
|
|
2009
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 24n);
|
|
2010
|
+
|
|
2011
|
+
// Compute checksum before compaction
|
|
2012
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2013
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2014
|
+
|
|
2015
|
+
// Compact with moveBatchQueryLimit=2 to force 3 batches of 2 documents each
|
|
2016
|
+
await bucketStorage.compact({
|
|
2017
|
+
clearBatchLimit: 200,
|
|
2018
|
+
moveBatchLimit: 10,
|
|
2019
|
+
moveBatchQueryLimit: 2,
|
|
2020
|
+
minBucketChanges: 1,
|
|
2021
|
+
minChangeRatio: 0,
|
|
2022
|
+
maxOpId: 24n
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
// Verify checksum preserved
|
|
2026
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2027
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2028
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2029
|
+
|
|
2030
|
+
// Verify MOVE tombstones for superseded old versions (A@10, B@6, C@2)
|
|
2031
|
+
const allOps = await readAllOps(collection);
|
|
2032
|
+
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
2033
|
+
expect(moveOps.length).toBe(3);
|
|
2034
|
+
expect(moveOps.map((op) => op.o).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([2n, 6n, 10n]);
|
|
2035
|
+
|
|
2036
|
+
// Verify the newer version of each duplicated row survives as PUT
|
|
2037
|
+
const putOps = allOps.filter((op) => op.op === 'PUT');
|
|
2038
|
+
expect(putOps.length).toBe(16);
|
|
2039
|
+
const putRowIds = putOps.map((op) => op.row_id).sort();
|
|
2040
|
+
expect(putRowIds).toContain('A');
|
|
2041
|
+
expect(putRowIds).toContain('B');
|
|
2042
|
+
expect(putRowIds).toContain('C');
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
test('2. scoped delete isolation - ops above maxOpId preserved', async () => {
|
|
2046
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2047
|
+
|
|
2048
|
+
// Insert ops in _id.o range 100-500 via direct document insertion
|
|
2049
|
+
const doc1 = serializeBucketData(BUCKET, [
|
|
2050
|
+
makeOp(100, 'A', 'a1', ctx, sourceTableId),
|
|
2051
|
+
makeOp(200, 'B', 'b1', ctx, sourceTableId),
|
|
2052
|
+
makeOp(300, 'C', 'c1', ctx, sourceTableId)
|
|
2053
|
+
]);
|
|
2054
|
+
const doc2 = serializeBucketData(BUCKET, [
|
|
2055
|
+
makeOp(400, 'D', 'd1', ctx, sourceTableId),
|
|
2056
|
+
makeOp(500, 'E', 'e1', ctx, sourceTableId)
|
|
2057
|
+
]);
|
|
2058
|
+
// One extra document with _id.o=600 — clearly outside the batch range
|
|
2059
|
+
const docOutside = serializeBucketData(BUCKET, [makeOp(600, 'F', 'f_outside', ctx, sourceTableId)]);
|
|
2060
|
+
|
|
2061
|
+
await insertDocs(collection, [doc1, doc2, docOutside]);
|
|
2062
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 600n);
|
|
2063
|
+
|
|
2064
|
+
// Run compaction with maxOpId=500n
|
|
2065
|
+
await bucketStorage.compact({
|
|
2066
|
+
clearBatchLimit: 200,
|
|
2067
|
+
moveBatchLimit: 10,
|
|
2068
|
+
moveBatchQueryLimit: 2,
|
|
2069
|
+
minBucketChanges: 1,
|
|
2070
|
+
minChangeRatio: 0,
|
|
2071
|
+
maxOpId: 500n
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
// The document at _id.o=600 must still be present and unmodified after compaction.
|
|
2075
|
+
// The streaming compactor should only touch documents containing ops <= maxOpId.
|
|
2076
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2077
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
2078
|
+
const op600 = allOpsAfter.find((op) => op.o === 600n);
|
|
2079
|
+
expect(op600).toBeDefined();
|
|
2080
|
+
expect(op600!.op).toBe('PUT');
|
|
2081
|
+
expect(op600!.row_id).toBe('F');
|
|
2082
|
+
|
|
2083
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2084
|
+
expect(state?.bucket_stats).toEqual({
|
|
2085
|
+
count: docsAfter.reduce((total, document) => total + document.count, 0),
|
|
2086
|
+
bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
|
|
2087
|
+
chunks: docsAfter.length
|
|
2088
|
+
});
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
|
|
2092
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2093
|
+
|
|
2094
|
+
// 20 rows, each with 2 versions (old + new). 40 ops total.
|
|
2095
|
+
// memoryLimitMB=0.001 → idLimitBytes ≈ 1048 bytes.
|
|
2096
|
+
// The seen map adds ~140 bytes per entry (key.length + 140), so it can hold ~7 entries
|
|
2097
|
+
// before overflowing. The streaming compactor processes ops newest-first; after ~7 rows
|
|
2098
|
+
// are tracked, the seen map overflows and subsequent old-version ops are not tombstoned.
|
|
2099
|
+
const rows = Array.from({ length: 20 }, (_, i) => `row${i + 1}`);
|
|
2100
|
+
const allOps: BucketDataDoc[] = [];
|
|
2101
|
+
|
|
2102
|
+
// Old versions at op_ids 1-20 (processed last by descending-order compactor)
|
|
2103
|
+
for (let i = 0; i < 20; i++) {
|
|
2104
|
+
allOps.push(makeOp(i + 1, rows[i], `old_${rows[i]}`, ctx, sourceTableId));
|
|
2105
|
+
}
|
|
2106
|
+
// New versions at op_ids 21-40 (processed first by descending-order compactor)
|
|
2107
|
+
for (let i = 0; i < 20; i++) {
|
|
2108
|
+
allOps.push(makeOp(i + 21, rows[i], `new_${rows[i]}`, ctx, sourceTableId));
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// Single document containing all ops
|
|
2112
|
+
const doc = serializeBucketData(BUCKET, allOps);
|
|
2113
|
+
await insertDocs(collection, [doc]);
|
|
2114
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
2115
|
+
|
|
2116
|
+
// Compute checksum before compaction
|
|
2117
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2118
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2119
|
+
|
|
2120
|
+
await bucketStorage.compact({
|
|
2121
|
+
clearBatchLimit: 200,
|
|
2122
|
+
moveBatchLimit: 10,
|
|
2123
|
+
moveBatchQueryLimit: 100,
|
|
2124
|
+
minBucketChanges: 1,
|
|
2125
|
+
minChangeRatio: 0,
|
|
2126
|
+
maxOpId: 40n,
|
|
2127
|
+
memoryLimitMB: 0.001
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
// Bucket checksum must be correct even with overflow — old ops that pass through
|
|
2131
|
+
// as PUT still contribute their original checksum.
|
|
2132
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2133
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2134
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2135
|
+
|
|
2136
|
+
// With seen map overflow, fewer than 20 old-version ops become MOVE tombstones.
|
|
2137
|
+
// Some old-version ops pass through as PUT because the seen map was full when
|
|
2138
|
+
// the compactor encountered them. With ~1048 bytes and ~176 bytes per entry,
|
|
2139
|
+
// approximately 5-7 rows are tracked before overflow, producing ~5-7 MOVE
|
|
2140
|
+
// tombstones from the 20 duplicate pairs.
|
|
2141
|
+
const opsAfter = await readAllOps(collection);
|
|
2142
|
+
const moveOps = opsAfter.filter((op) => op.op === 'MOVE');
|
|
2143
|
+
expect(moveOps.length).toBeGreaterThan(0);
|
|
2144
|
+
expect(moveOps.length).toBeLessThanOrEqual(12);
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
test('5. scoped delete does not remove sandwiched non-processable docs', async () => {
|
|
2148
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2149
|
+
|
|
2150
|
+
// Doc1: processable (has ops <= maxOpId=300), _id.o = 300
|
|
2151
|
+
const doc1 = serializeBucketData(BUCKET, [
|
|
2152
|
+
makeOp(200, 'A', 'a1', ctx, sourceTableId),
|
|
2153
|
+
makeOp(300, 'B', 'b1', ctx, sourceTableId)
|
|
2154
|
+
]);
|
|
2155
|
+
// Doc2: NON-processable (all ops > maxOpId=300), _id.o = 350
|
|
2156
|
+
// This doc is sandwiched between doc1 (_id.o=300) and doc3 (_id.o=500)
|
|
2157
|
+
// in _id.o sort order. A continuous range delete [200, 500] would
|
|
2158
|
+
// incorrectly catch this doc.
|
|
2159
|
+
const doc2 = serializeBucketData(BUCKET, [
|
|
2160
|
+
makeOp(340, 'X', 'x_sandwich', ctx, sourceTableId),
|
|
2161
|
+
makeOp(350, 'Y', 'y_sandwich', ctx, sourceTableId)
|
|
2162
|
+
]);
|
|
2163
|
+
// Doc3: processable (has ops <= maxOpId=300), _id.o = 400
|
|
2164
|
+
const doc3 = serializeBucketData(BUCKET, [
|
|
2165
|
+
makeOp(250, 'C', 'c1', ctx, sourceTableId),
|
|
2166
|
+
makeOp(400, 'D', 'd1', ctx, sourceTableId)
|
|
2167
|
+
]);
|
|
2168
|
+
|
|
2169
|
+
await insertDocs(collection, [doc1, doc2, doc3]);
|
|
2170
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
|
|
2171
|
+
|
|
2172
|
+
await bucketStorage.compact({
|
|
2173
|
+
clearBatchLimit: 200,
|
|
2174
|
+
moveBatchLimit: 10,
|
|
2175
|
+
moveBatchQueryLimit: 10,
|
|
2176
|
+
minBucketChanges: 1,
|
|
2177
|
+
minChangeRatio: 0,
|
|
2178
|
+
maxOpId: 300n
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// The sandwiched document (doc2, ops 340+350) must survive because ALL
|
|
2182
|
+
// its ops are > maxOpId — it should not be touched by compaction.
|
|
2183
|
+
const opsAfter = await readAllOps(collection);
|
|
2184
|
+
const sandwichOps = opsAfter.filter((op) => op.row_id === 'X' || op.row_id === 'Y');
|
|
2185
|
+
expect(sandwichOps.length).toBe(2);
|
|
2186
|
+
expect(sandwichOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
test('6. byte-based read batches do not constrain output merging', async () => {
|
|
2190
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2191
|
+
|
|
2192
|
+
// 6 documents, each with 2 PUT ops. With moveBatchByteLimit=400,
|
|
2193
|
+
// the byte limit should be hit after 1-2 documents, forcing multiple
|
|
2194
|
+
// batch iterations even though the document count limit (10) is never reached.
|
|
2195
|
+
const rows = Array.from({ length: 12 }, (_, i) => `row${i + 1}`);
|
|
2196
|
+
const docs: BucketDataDocumentV3[] = [];
|
|
2197
|
+
for (let i = 0; i < 6; i++) {
|
|
2198
|
+
docs.push(
|
|
2199
|
+
serializeBucketData(BUCKET, [
|
|
2200
|
+
makeOp(i * 2 + 1, rows[i * 2], `data_${rows[i * 2]}`, ctx, sourceTableId),
|
|
2201
|
+
makeOp(i * 2 + 2, rows[i * 2 + 1], `data_${rows[i * 2 + 1]}`, ctx, sourceTableId)
|
|
2202
|
+
])
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
expect(docs[0].size + docs[1].size).toBeGreaterThan(400);
|
|
2206
|
+
await insertDocs(collection, docs);
|
|
2207
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 12n);
|
|
2208
|
+
|
|
2209
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2210
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2211
|
+
|
|
2212
|
+
await bucketStorage.compact({
|
|
2213
|
+
clearBatchLimit: 200,
|
|
2214
|
+
moveBatchLimit: 10,
|
|
2215
|
+
moveBatchQueryLimit: 10,
|
|
2216
|
+
moveBatchByteLimit: 400,
|
|
2217
|
+
minBucketChanges: 1,
|
|
2218
|
+
minChangeRatio: 0,
|
|
2219
|
+
maxOpId: 12n
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2223
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2224
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2225
|
+
|
|
2226
|
+
// All 12 ops should survive — no superseding, just byte-based batching
|
|
2227
|
+
const allOps = await readAllOps(collection);
|
|
2228
|
+
expect(allOps.length).toBe(12);
|
|
2229
|
+
expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
2230
|
+
|
|
2231
|
+
// Read batches are only a memory bound. The pending output group crosses
|
|
2232
|
+
// those boundaries and merges all post-compaction results that fit.
|
|
2233
|
+
expect(docsAfter).toHaveLength(1);
|
|
2234
|
+
expect(docsAfter[0]).toMatchObject({
|
|
2235
|
+
min_op: docs[0].min_op,
|
|
2236
|
+
_id: docs[docs.length - 1]._id
|
|
2237
|
+
});
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
test('7. cross-batch seen map overflow - dedup continues across batches', async () => {
|
|
2241
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2242
|
+
|
|
2243
|
+
// 10 rows (A-J), 2 versions each. 20 docs total, 1 PUT per doc.
|
|
2244
|
+
// New versions at opIds 20-11 (docs 1-10), old versions at opIds 10-1 (docs 11-20).
|
|
2245
|
+
// Processed newest-first: new versions fill the seen map; old versions that
|
|
2246
|
+
// were tracked get tombstoned; old versions beyond map capacity survive as PUT.
|
|
2247
|
+
const rows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
|
|
2248
|
+
const docs: BucketDataDocumentV3[] = [];
|
|
2249
|
+
|
|
2250
|
+
// New versions first (higher opIds — processed first in reverse order)
|
|
2251
|
+
for (let i = 0; i < 10; i++) {
|
|
2252
|
+
docs.push(
|
|
2253
|
+
serializeBucketData(BUCKET, [makeOp(20 - i, rows[i], `${rows[i].toLowerCase()}_new`, ctx, sourceTableId)])
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
// Old versions (lower opIds — processed later)
|
|
2257
|
+
for (let i = 0; i < 10; i++) {
|
|
2258
|
+
docs.push(
|
|
2259
|
+
serializeBucketData(BUCKET, [makeOp(10 - i, rows[i], `${rows[i].toLowerCase()}_old`, ctx, sourceTableId)])
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
await insertDocs(collection, docs);
|
|
2263
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
|
|
2264
|
+
|
|
2265
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2266
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2267
|
+
|
|
2268
|
+
// moveBatchQueryLimit=5 forces 4 cross-batch iterations. memoryLimitMB=0.001
|
|
2269
|
+
// limits the seen map to ~6-7 entries, so overflow occurs mid-processing.
|
|
2270
|
+
await bucketStorage.compact({
|
|
2271
|
+
clearBatchLimit: 200,
|
|
2272
|
+
moveBatchLimit: 10,
|
|
2273
|
+
moveBatchQueryLimit: 5,
|
|
2274
|
+
minBucketChanges: 1,
|
|
2275
|
+
minChangeRatio: 0,
|
|
2276
|
+
maxOpId: 20n,
|
|
2277
|
+
memoryLimitMB: 0.001
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2281
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2282
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2283
|
+
|
|
2284
|
+
const allOps = await readAllOps(collection);
|
|
2285
|
+
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
2286
|
+
|
|
2287
|
+
// At least some rows were tracked before overflow → MOVE tombstones exist
|
|
2288
|
+
expect(moveOps.length).toBeGreaterThan(0);
|
|
2289
|
+
|
|
2290
|
+
// Some old-version ops (o <= 10) survive as PUT — overflow prevented tracking
|
|
2291
|
+
const oldVersionPutOps = allOps.filter((op) => op.op === 'PUT' && op.o <= 10n);
|
|
2292
|
+
expect(oldVersionPutOps.length).toBeGreaterThan(0);
|
|
2293
|
+
|
|
2294
|
+
// Not all 10 old versions became MOVEs (overflow occurred)
|
|
2295
|
+
expect(moveOps.length).toBeLessThan(10);
|
|
2296
|
+
|
|
2297
|
+
// MOVEs have target_op stored at the document level (not per-op in V3).
|
|
2298
|
+
// Documents containing MOVEs should have non-null target_op.
|
|
2299
|
+
const docsWithMoves = docsAfter.filter((d) => d.ops!.some((op: any) => op.op === 'MOVE'));
|
|
2300
|
+
expect(docsWithMoves.length).toBeGreaterThan(0);
|
|
2301
|
+
for (const doc of docsWithMoves) {
|
|
2302
|
+
expect(doc.target_op).toBeDefined();
|
|
2303
|
+
expect(doc.target_op).not.toBeNull();
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
test('8. all ops above maxOpId - paginates without writing', async () => {
|
|
2308
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2309
|
+
|
|
2310
|
+
// 5 docs, each with 1 PUT op at high opIds (all > maxOpId=10).
|
|
2311
|
+
// The compactor should paginate through batches where processableDocs
|
|
2312
|
+
// is always empty, terminating cleanly without touching any data.
|
|
2313
|
+
const docs: BucketDataDocumentV3[] = [];
|
|
2314
|
+
for (let i = 0; i < 5; i++) {
|
|
2315
|
+
docs.push(serializeBucketData(BUCKET, [makeOp((i + 1) * 100, `row${i}`, `data${i}`, ctx, sourceTableId)]));
|
|
2316
|
+
}
|
|
2317
|
+
await insertDocs(collection, docs);
|
|
2318
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 500n);
|
|
2319
|
+
|
|
2320
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2321
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2322
|
+
|
|
2323
|
+
await bucketStorage.compact({
|
|
2324
|
+
clearBatchLimit: 200,
|
|
2325
|
+
moveBatchLimit: 10,
|
|
2326
|
+
moveBatchQueryLimit: 2,
|
|
2327
|
+
minBucketChanges: 1,
|
|
2328
|
+
minChangeRatio: 0,
|
|
2329
|
+
maxOpId: 10n
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
// All ops are > maxOpId, so processableDocs is always empty.
|
|
2333
|
+
// The compactor should paginate through all documents without
|
|
2334
|
+
// deleting or modifying anything.
|
|
2335
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2336
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2337
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2338
|
+
|
|
2339
|
+
// All 5 docs should still exist with their PUT ops intact
|
|
2340
|
+
expect(docsAfter.length).toBe(5);
|
|
2341
|
+
const allOps = await readAllOps(collection);
|
|
2342
|
+
expect(allOps.length).toBe(5);
|
|
2343
|
+
expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
test('9. maxOpId filtering leaves a straddling document untouched', async () => {
|
|
2347
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2348
|
+
|
|
2349
|
+
// Single document with two ops: one <= maxOpId, one > maxOpId
|
|
2350
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2351
|
+
makeOp(200, 'A', 'old_A', ctx, sourceTableId),
|
|
2352
|
+
makeOp(400, 'B', 'new_B', ctx, sourceTableId)
|
|
2353
|
+
]);
|
|
2354
|
+
await insertDocs(collection, [doc]);
|
|
2355
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
|
|
2356
|
+
|
|
2357
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2358
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2359
|
+
|
|
2360
|
+
await bucketStorage.compact({
|
|
2361
|
+
clearBatchLimit: 200,
|
|
2362
|
+
moveBatchLimit: 10,
|
|
2363
|
+
moveBatchQueryLimit: 10,
|
|
2364
|
+
minBucketChanges: 1,
|
|
2365
|
+
minChangeRatio: 0,
|
|
2366
|
+
maxOpId: 300n
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2370
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2371
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2372
|
+
|
|
2373
|
+
const allOps = await readAllOps(collection);
|
|
2374
|
+
expect(allOps.length).toBe(2);
|
|
2375
|
+
|
|
2376
|
+
const op400 = allOps.find((op) => op.o === 400n);
|
|
2377
|
+
expect(op400).toBeDefined();
|
|
2378
|
+
expect(op400!.op).toBe('PUT');
|
|
2379
|
+
expect(op400!.row_id).toBe('B');
|
|
2380
|
+
});
|
|
2381
|
+
|
|
2382
|
+
test('10. compacted documents have non-overlapping ranges', async () => {
|
|
2383
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2384
|
+
|
|
2385
|
+
// Four documents with truly disjoint [min_op, _id.o] ranges.
|
|
2386
|
+
// maxOpId=350: only doc1 is processable; doc2 straddles the horizon.
|
|
2387
|
+
// Compaction must preserve range disjointness after rechunking.
|
|
2388
|
+
const doc1 = serializeBucketData(BUCKET, [
|
|
2389
|
+
makeOp(100, 'A', 'a1', ctx, sourceTableId),
|
|
2390
|
+
makeOp(200, 'B', 'b1', ctx, sourceTableId)
|
|
2391
|
+
]); // range [100, 200]
|
|
2392
|
+
const doc2 = serializeBucketData(BUCKET, [
|
|
2393
|
+
makeOp(300, 'C', 'c1', ctx, sourceTableId),
|
|
2394
|
+
makeOp(400, 'D', 'd1', ctx, sourceTableId)
|
|
2395
|
+
]); // range [300, 400], mixed (300≤350, 400>350)
|
|
2396
|
+
const doc3 = serializeBucketData(BUCKET, [
|
|
2397
|
+
makeOp(500, 'E', 'e1', ctx, sourceTableId),
|
|
2398
|
+
makeOp(600, 'F', 'f1', ctx, sourceTableId)
|
|
2399
|
+
]); // range [500, 600], all >350
|
|
2400
|
+
const doc4 = serializeBucketData(BUCKET, [
|
|
2401
|
+
makeOp(700, 'G', 'g1', ctx, sourceTableId),
|
|
2402
|
+
makeOp(800, 'H', 'h1', ctx, sourceTableId)
|
|
2403
|
+
]); // range [700, 800], all >350
|
|
2404
|
+
|
|
2405
|
+
await insertDocs(collection, [doc1, doc2, doc3, doc4]);
|
|
2406
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 800n);
|
|
2407
|
+
|
|
2408
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2409
|
+
const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2410
|
+
|
|
2411
|
+
await bucketStorage.compact({
|
|
2412
|
+
clearBatchLimit: 200,
|
|
2413
|
+
moveBatchLimit: 10,
|
|
2414
|
+
moveBatchQueryLimit: 10,
|
|
2415
|
+
minBucketChanges: 1,
|
|
2416
|
+
minChangeRatio: 0,
|
|
2417
|
+
maxOpId: 350n
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2421
|
+
|
|
2422
|
+
// Every pair of documents must have disjoint [min_op, _id.o] ranges.
|
|
2423
|
+
const overlaps = docsAfter.flatMap((a, i) =>
|
|
2424
|
+
docsAfter
|
|
2425
|
+
.slice(i + 1)
|
|
2426
|
+
.filter((b) => a._id.o >= b.min_op && b._id.o >= a.min_op)
|
|
2427
|
+
.map((b) => `[${a.min_op}, ${a._id.o}] vs [${b.min_op}, ${b._id.o}]`)
|
|
2428
|
+
);
|
|
2429
|
+
expect(overlaps).toEqual([]);
|
|
2430
|
+
|
|
2431
|
+
// Checksum must be preserved across compaction
|
|
2432
|
+
const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2433
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
describe('V3 CLEAR pass', () => {
|
|
2437
|
+
async function doCompact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
|
|
2438
|
+
await bucketStorage.compact({
|
|
2439
|
+
clearBatchLimit: 200,
|
|
2440
|
+
moveBatchLimit: 10,
|
|
2441
|
+
moveBatchQueryLimit: 10,
|
|
2442
|
+
minBucketChanges: 1,
|
|
2443
|
+
minChangeRatio: 0,
|
|
2444
|
+
maxOpId
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// Helper to find a CLEAR op in a bucket
|
|
2449
|
+
async function hasClearOp(collection: any): Promise<boolean> {
|
|
2450
|
+
const allOps = await readAllOps(collection);
|
|
2451
|
+
return allOps.some((op) => op.op === 'CLEAR');
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Helper to assert checksum preservation before/after compact
|
|
2455
|
+
async function assertChecksumPreserved(collection: any, compactFn: () => Promise<void>) {
|
|
2456
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2457
|
+
const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2458
|
+
await compactFn();
|
|
2459
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2460
|
+
const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2461
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
test('duplicate rows produce CLEAR op', async () => {
|
|
2465
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2466
|
+
|
|
2467
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2468
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
2469
|
+
makeOp(20, 'A', 'a2', ctx, sourceTableId),
|
|
2470
|
+
makeOp(30, 'A', 'a3', ctx, sourceTableId)
|
|
2471
|
+
]);
|
|
2472
|
+
await insertDocs(collection, [doc]);
|
|
2473
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
2474
|
+
|
|
2475
|
+
await doCompact(bucketStorage, 30n);
|
|
2476
|
+
|
|
2477
|
+
expect(await hasClearOp(collection)).toBe(true);
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
test('unique rows produce no CLEAR op', async () => {
|
|
2481
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2482
|
+
|
|
2483
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2484
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
2485
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId)
|
|
2486
|
+
]);
|
|
2487
|
+
await insertDocs(collection, [doc]);
|
|
2488
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
|
|
2489
|
+
|
|
2490
|
+
await doCompact(bucketStorage, 20n);
|
|
2491
|
+
|
|
2492
|
+
expect(await hasClearOp(collection)).toBe(false);
|
|
2493
|
+
const allOps = await readAllOps(collection);
|
|
2494
|
+
expect(allOps.length).toBe(2);
|
|
2495
|
+
expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
test('end-to-end: duplicate + unique rows, checksum preserved', async () => {
|
|
2499
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2500
|
+
|
|
2501
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2502
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
2503
|
+
makeOp(20, 'A', 'a2', ctx, sourceTableId),
|
|
2504
|
+
makeOp(30, 'B', 'b1', ctx, sourceTableId),
|
|
2505
|
+
makeOp(40, 'A', 'a3', ctx, sourceTableId)
|
|
2506
|
+
]);
|
|
2507
|
+
await insertDocs(collection, [doc]);
|
|
2508
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
2509
|
+
|
|
2510
|
+
await assertChecksumPreserved(collection, () => doCompact(bucketStorage, 40n));
|
|
2511
|
+
|
|
2512
|
+
expect(await hasClearOp(collection)).toBe(true);
|
|
2513
|
+
const allOps = await readAllOps(collection);
|
|
2514
|
+
const putOps = allOps.filter((op) => op.op === 'PUT');
|
|
2515
|
+
expect(putOps.length).toBe(2);
|
|
2516
|
+
expect(putOps.some((op) => op.row_id === 'B' && op.o === 30n)).toBe(true);
|
|
2517
|
+
expect(putOps.some((op) => op.row_id === 'A' && op.o === 40n)).toBe(true);
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
// 15 MOVE ops, each in its own document. moveBatchQueryLimit=1
|
|
2521
|
+
// forces the MOVE pass to produce 15 separate rechunked documents
|
|
2522
|
+
// (one per batch). clearBatchLimit=5 forces the CLEAR read loop
|
|
2523
|
+
// to paginate through 3 iterations.
|
|
2524
|
+
test('read pagination iterates across multiple read batches', async () => {
|
|
2525
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2526
|
+
|
|
2527
|
+
const docs: BucketDataDocumentV3[] = [];
|
|
2528
|
+
for (let i = 1; i <= 15; i++) {
|
|
2529
|
+
const op = { ...makeOp(i, `row_${i}`, '', ctx, sourceTableId), op: 'MOVE' as const, data: null };
|
|
2530
|
+
docs.push(serializeBucketData(BUCKET, [op]));
|
|
2531
|
+
}
|
|
2532
|
+
await insertDocs(collection, docs);
|
|
2533
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 15n);
|
|
2534
|
+
|
|
2535
|
+
const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2536
|
+
const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2537
|
+
|
|
2538
|
+
await bucketStorage.compact({
|
|
2539
|
+
clearBatchLimit: 5,
|
|
2540
|
+
moveBatchLimit: 1,
|
|
2541
|
+
moveBatchQueryLimit: 1,
|
|
2542
|
+
minBucketChanges: 1,
|
|
2543
|
+
minChangeRatio: 0,
|
|
2544
|
+
maxOpId: 15n
|
|
2545
|
+
});
|
|
2546
|
+
|
|
2547
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2548
|
+
const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
|
|
2549
|
+
expect(checksumAfter).toBe(checksumBefore);
|
|
2550
|
+
|
|
2551
|
+
expect(await hasClearOp(collection)).toBe(true);
|
|
2552
|
+
const allOps = await readAllOps(collection);
|
|
2553
|
+
expect(allOps.length).toBe(1);
|
|
2554
|
+
expect(allOps[0].op).toBe('CLEAR');
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
test('CLEAR document carries target_op from collapsed MOVE tombstones', async () => {
|
|
2558
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2559
|
+
|
|
2560
|
+
// Three versions of row A: ops 10, 20, 30.
|
|
2561
|
+
// MOVE pass: 30 survives, 20 and 10 become MOVE tombstones (target=30).
|
|
2562
|
+
// CLEAR pass: collapses both MOVEs. The CLEAR doc should carry target_op=30.
|
|
2563
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2564
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
2565
|
+
makeOp(20, 'A', 'a2', ctx, sourceTableId),
|
|
2566
|
+
makeOp(30, 'A', 'a3', ctx, sourceTableId)
|
|
2567
|
+
]);
|
|
2568
|
+
await insertDocs(collection, [doc]);
|
|
2569
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
|
|
2570
|
+
|
|
2571
|
+
await doCompact(bucketStorage, 30n);
|
|
2572
|
+
|
|
2573
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2574
|
+
const clearDoc = docsAfter.find((d: any) => d.ops!.some((op: any) => op.op === 'CLEAR'));
|
|
2575
|
+
expect(clearDoc).toBeDefined();
|
|
2576
|
+
expect(clearDoc!.target_op).toBe(30n);
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
test('straddling document is excluded from compacted state', async () => {
|
|
2580
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2581
|
+
|
|
2582
|
+
// Row A at 10, 30, 50 — ops 10+30 ≤ maxOpId=40, op 50 > 40 (pass-through)
|
|
2583
|
+
// Row B at 20 — ≤ 40, first occurrence
|
|
2584
|
+
// Row C at 60 — > 40 (pass-through, no earlier version)
|
|
2585
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2586
|
+
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
2587
|
+
makeOp(20, 'B', 'b1', ctx, sourceTableId),
|
|
2588
|
+
makeOp(30, 'A', 'a2', ctx, sourceTableId),
|
|
2589
|
+
makeOp(50, 'A', 'a3', ctx, sourceTableId),
|
|
2590
|
+
makeOp(60, 'C', 'c1', ctx, sourceTableId)
|
|
2591
|
+
]);
|
|
2592
|
+
await insertDocs(collection, [doc]);
|
|
2593
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
|
|
2594
|
+
|
|
2595
|
+
await doCompact(bucketStorage, 40n);
|
|
2596
|
+
|
|
2597
|
+
const state = await bucketStateCollection.findOne({
|
|
2598
|
+
_id: { d: ctx.definitionId, b: BUCKET }
|
|
2599
|
+
});
|
|
2600
|
+
expect(state).toBeDefined();
|
|
2601
|
+
expect(state!.compacted_state).toBeUndefined();
|
|
2602
|
+
|
|
2603
|
+
// The document ends beyond maxOpId, so it is not read or modified.
|
|
2604
|
+
const allOps = await readAllOps(collection);
|
|
2605
|
+
expect(allOps.length).toBe(5);
|
|
2606
|
+
expect(allOps.every((op) => op.op == 'PUT')).toBe(true);
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
test('upperBound pagination prevents re-reading replacement documents', async () => {
|
|
2610
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2611
|
+
|
|
2612
|
+
// Two distinct-row PUTs at ~600KB each. Rechunking produces two
|
|
2613
|
+
// documents: C1(_id.o=10) and C2(_id.o=30). The fix sets upperBound
|
|
2614
|
+
// from the oldest replacement C1, so _id < { o: 10 } excludes both.
|
|
2615
|
+
// No re-read. Both PUTs survive with intact data and valid row_ids.
|
|
2616
|
+
const largeData = 'x'.repeat(600_000);
|
|
2617
|
+
const doc = serializeBucketData(BUCKET, [
|
|
2618
|
+
makeOp(10, 'A', largeData, ctx, sourceTableId),
|
|
2619
|
+
makeOp(30, 'B', largeData, ctx, sourceTableId)
|
|
2620
|
+
]);
|
|
2621
|
+
await insertDocs(collection, [doc]);
|
|
2622
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
|
|
2623
|
+
|
|
2624
|
+
await bucketStorage.compact({
|
|
2625
|
+
clearBatchLimit: 200,
|
|
2626
|
+
moveBatchLimit: 10,
|
|
2627
|
+
moveBatchQueryLimit: 1,
|
|
2628
|
+
minBucketChanges: 1,
|
|
2629
|
+
minChangeRatio: 0,
|
|
2630
|
+
maxOpId: 40n
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2634
|
+
const rawOps = docsAfter.flatMap((d: any) => d.ops) as any[];
|
|
2635
|
+
|
|
2636
|
+
// Row A@10 must survive as PUT with data intact
|
|
2637
|
+
const rowA = rawOps.find((op: any) => op.row_id === 'A' && op.o === 10n);
|
|
2638
|
+
expect(rowA).toBeDefined();
|
|
2639
|
+
expect(rowA!.op).toBe('PUT');
|
|
2640
|
+
expect(rowA!.data).not.toBeNull();
|
|
2641
|
+
|
|
2642
|
+
// Row B@30 must survive as PUT with data intact
|
|
2643
|
+
const rowB = rawOps.find((op: any) => op.row_id === 'B' && op.o === 30n);
|
|
2644
|
+
expect(rowB).toBeDefined();
|
|
2645
|
+
expect(rowB!.op).toBe('PUT');
|
|
2646
|
+
expect(rowB!.data).not.toBeNull();
|
|
2647
|
+
|
|
2648
|
+
// Checksum preserved
|
|
2649
|
+
const expectedChecksum = [10, 30].reduce((sum, id) => addChecksums(sum, id * 7), 0);
|
|
2650
|
+
const checksumAfter = docsAfter.reduce((s: number, d: any) => addChecksums(s, Number(d.checksum)), 0);
|
|
2651
|
+
expect(checksumAfter).toBe(expectedChecksum);
|
|
2652
|
+
});
|
|
2653
|
+
});
|
|
245
2654
|
});
|