@powersync/service-module-mongodb-storage 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716
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 +129 -7
- package/dist/storage/MongoBucketStorage.d.ts +42 -5
- package/dist/storage/MongoBucketStorage.js +404 -215
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +39 -14
- package/dist/storage/implementation/MongoBucketBatch.js +169 -98
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +21 -79
- package/dist/storage/implementation/MongoCompactor.js +27 -238
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
- package/dist/storage/implementation/MongoIdSequence.js +14 -1
- package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
- package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
- package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +35 -2
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +2 -1
- package/dist/storage/implementation/MongoPersistedReplicationStream.js +7 -4
- package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +3 -3
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +10 -8
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +8 -4
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +32 -11
- package/dist/storage/implementation/MongoSyncBucketStorage.js +95 -31
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +59 -10
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
- package/dist/storage/implementation/MongoWriteBatch.js +149 -0
- package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
- 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 +22 -2
- package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
- package/dist/storage/implementation/common/PersistedBatch.js +57 -14
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +9 -1
- package/dist/storage/implementation/db.js +28 -1
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +35 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +11 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +227 -221
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
- package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
- 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 +18 -11
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +66 -17
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
- package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -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/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 +14 -2
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +261 -194
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +83 -25
- package/dist/storage/implementation/v3/MongoCompactorV3.js +702 -159
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +92 -32
- 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 +14 -8
- package/dist/storage/implementation/v3/PersistedBatchV3.js +77 -45
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.js +2 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/compact-utils.d.ts +108 -0
- package/dist/storage/implementation/v3/compact-utils.js +223 -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 +90 -6
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
- package/dist/storage/implementation/v3/source-table-utils.js +49 -57
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
- package/dist/storage/storage-index.d.ts +2 -1
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +28 -1
- package/dist/types/types.js +28 -7
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.d.ts +4 -2
- package/dist/utils/test-utils.js +7 -4
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.js +3 -1
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -9
- package/src/storage/MongoBucketStorage.ts +545 -277
- package/src/storage/implementation/MongoBucketBatch.ts +258 -124
- package/src/storage/implementation/MongoCompactor.ts +37 -346
- package/src/storage/implementation/MongoIdSequence.ts +21 -1
- package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
- package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +46 -1
- package/src/storage/implementation/MongoPersistedReplicationStream.ts +12 -4
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +48 -29
- package/src/storage/implementation/MongoStorageProvider.ts +16 -4
- package/src/storage/implementation/MongoSyncBucketStorage.ts +133 -40
- package/src/storage/implementation/MongoSyncRulesLock.ts +78 -12
- package/src/storage/implementation/MongoWriteBatch.ts +172 -0
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
- package/src/storage/implementation/common/PersistedBatch.ts +76 -20
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
- package/src/storage/implementation/db.ts +36 -2
- package/src/storage/implementation/models.ts +39 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +272 -254
- package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +88 -28
- package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
- package/src/storage/implementation/v1/models.ts +1 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +339 -247
- package/src/storage/implementation/v3/MongoCompactorV3.ts +955 -225
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +112 -42
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +94 -51
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
- package/src/storage/implementation/v3/bucket-format.ts +2 -0
- package/src/storage/implementation/v3/compact-utils.ts +329 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +97 -4
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
- package/src/storage/implementation/v3/source-table-utils.ts +82 -76
- package/src/storage/storage-index.ts +2 -0
- package/src/types/types.ts +42 -7
- package/src/utils/test-utils.ts +9 -5
- package/src/utils/util.ts +3 -1
- package/test/src/__snapshots__/storage.test.ts.snap +25 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +794 -205
- package/test/src/chunk_compaction_config.test.ts +38 -0
- package/test/src/cleanup-stopped-sync-configs.test.ts +59 -26
- package/test/src/compact-utils.test.ts +251 -0
- package/test/src/concurrent_writers.test.ts +599 -0
- package/test/src/connection_config.test.ts +159 -0
- package/test/src/helpers/MemoryObjectStorage.ts +14 -6
- package/test/src/helpers/concurrentWriter.mjs +29 -0
- package/test/src/helpers/s3TestFactory.ts +12 -12
- package/test/src/object_storage_usage.test.ts +268 -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/storage.test.ts +280 -24
- package/test/src/storage_compacting.test.ts +1097 -253
- package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
- package/test/src/storage_s3_checksums.test.ts +6 -7
- package/test/src/storage_s3_compaction_lifecycle.test.ts +143 -17
- package/test/src/storage_s3_reading.test.ts +294 -6
- package/test/src/storage_s3_writing.test.ts +4 -3
- package/test/src/storage_sync.test.ts +412 -80
- package/test/src/storeCurrentData.test.ts +5 -5
- package/test/src/util.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,8 +2,14 @@ import { BucketDataDoc } from '@module/storage/implementation/common/BucketDataD
|
|
|
2
2
|
import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
|
|
3
3
|
import { loadBucketDataDocument, serializeBucketData } from '@module/storage/implementation/v3/bucket-format.js';
|
|
4
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';
|
|
5
7
|
import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
|
|
8
|
+
import { ObjectStorageError } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
|
|
6
9
|
import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
10
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
11
|
+
import { replicaIdToSubkey } from '@module/utils/util.js';
|
|
12
|
+
import { logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
7
13
|
import {
|
|
8
14
|
addChecksums,
|
|
9
15
|
CheckpointChecksumInvalidatedError,
|
|
@@ -11,10 +17,39 @@ import {
|
|
|
11
17
|
SyncRulesBucketStorage,
|
|
12
18
|
updateSyncRulesFromYaml
|
|
13
19
|
} from '@powersync/service-core';
|
|
14
|
-
import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
|
|
20
|
+
import { bucketRequest, compactActive, getTestStorage, register, test_utils } from '@powersync/service-core-tests';
|
|
15
21
|
import * as bson from 'bson';
|
|
16
|
-
import { describe, expect, test } from 'vitest';
|
|
17
|
-
import {
|
|
22
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
23
|
+
import { env } from './env.js';
|
|
24
|
+
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
25
|
+
import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
|
|
26
|
+
|
|
27
|
+
function makeOp(
|
|
28
|
+
opId: number,
|
|
29
|
+
rowId: string,
|
|
30
|
+
data: string,
|
|
31
|
+
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
32
|
+
sourceTableId: bson.ObjectId,
|
|
33
|
+
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
34
|
+
): BucketDataDoc {
|
|
35
|
+
const sourceKey = test_utils.rid(rowId);
|
|
36
|
+
return {
|
|
37
|
+
bucketKey: {
|
|
38
|
+
replicationStreamId: ctx.replicationStreamId,
|
|
39
|
+
definitionId: ctx.definitionId,
|
|
40
|
+
bucket: ctx.bucket
|
|
41
|
+
},
|
|
42
|
+
o: BigInt(opId),
|
|
43
|
+
op: overrides?.op ?? 'PUT',
|
|
44
|
+
source_table: sourceTableId,
|
|
45
|
+
source_key: sourceKey,
|
|
46
|
+
subkey: replicaIdToSubkey(sourceTableId, sourceKey),
|
|
47
|
+
table: 'items',
|
|
48
|
+
row_id: rowId,
|
|
49
|
+
checksum: BigInt(opId * 7),
|
|
50
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
51
|
+
};
|
|
52
|
+
}
|
|
18
53
|
|
|
19
54
|
describe('Mongo Sync Bucket Storage Compact', () => {
|
|
20
55
|
register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
@@ -58,37 +93,37 @@ describe('Mongo Sync Bucket Storage Compact', () => {
|
|
|
58
93
|
return bucketStorage.getCheckpoint();
|
|
59
94
|
};
|
|
60
95
|
|
|
61
|
-
const setup = async () => {
|
|
96
|
+
const setup = async (storageVersion?: number) => {
|
|
62
97
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
63
98
|
const syncRules = await factory.updateSyncRules(
|
|
64
|
-
updateSyncRulesFromYaml(
|
|
99
|
+
updateSyncRulesFromYaml(
|
|
100
|
+
`
|
|
65
101
|
bucket_definitions:
|
|
66
102
|
by_user:
|
|
67
103
|
parameters: select request.user_id() as user_id
|
|
68
104
|
data: [select * from test where owner_id = bucket.user_id]
|
|
69
|
-
|
|
105
|
+
`,
|
|
106
|
+
{ storageVersion }
|
|
107
|
+
)
|
|
70
108
|
);
|
|
71
|
-
const bucketStorage = factory
|
|
109
|
+
const bucketStorage = await getTestStorage(factory, syncRules);
|
|
72
110
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
73
111
|
const { checkpoint } = await populate(bucketStorage, 1);
|
|
74
112
|
|
|
75
113
|
return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
|
|
76
114
|
};
|
|
77
115
|
|
|
78
|
-
test('full compact', async () => {
|
|
116
|
+
test('V1 full compact with blank bucket_state', async () => {
|
|
79
117
|
const { bucketStorage, checkpoint, factory, syncRules } = await setup();
|
|
80
118
|
const storageDb = bucketStorage.db;
|
|
81
119
|
|
|
82
|
-
// Simulate bucket_state from old version not being available
|
|
83
120
|
if (storageDb.storageConfig.incrementalReprocessing) {
|
|
84
|
-
|
|
85
|
-
// Can remove this if it causes issues in the future.
|
|
86
|
-
await (storageDb as VersionedPowerSyncMongoV3).bucketState(bucketStorage.replicationStreamId).deleteMany({});
|
|
87
|
-
} else {
|
|
88
|
-
await factory.db.bucket_state.deleteMany({});
|
|
121
|
+
return;
|
|
89
122
|
}
|
|
123
|
+
// Simulate a V1 deployment which pre-dates bucket-state population.
|
|
124
|
+
await factory.db.bucket_state.deleteMany({});
|
|
90
125
|
|
|
91
|
-
await
|
|
126
|
+
await compactActive(factory, {
|
|
92
127
|
clearBatchLimit: 200,
|
|
93
128
|
moveBatchLimit: 10,
|
|
94
129
|
moveBatchQueryLimit: 10,
|
|
@@ -113,40 +148,48 @@ bucket_definitions:
|
|
|
113
148
|
});
|
|
114
149
|
});
|
|
115
150
|
|
|
116
|
-
test('
|
|
151
|
+
test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
|
|
117
152
|
// Populate old replication stream
|
|
118
|
-
const { factory } = await setup();
|
|
153
|
+
const { factory } = await setup(storageVersion);
|
|
119
154
|
|
|
120
155
|
// Now populate another replication stream (bucket definition name changed)
|
|
121
156
|
const syncRules = await factory.updateSyncRules(
|
|
122
|
-
updateSyncRulesFromYaml(
|
|
157
|
+
updateSyncRulesFromYaml(
|
|
158
|
+
`
|
|
123
159
|
bucket_definitions:
|
|
124
160
|
by_user2:
|
|
125
161
|
parameters: select request.user_id() as user_id
|
|
126
162
|
data: [select * from test where owner_id = bucket.user_id]
|
|
127
|
-
|
|
163
|
+
`,
|
|
164
|
+
{ storageVersion }
|
|
165
|
+
)
|
|
128
166
|
);
|
|
129
|
-
const bucketStorage = factory
|
|
167
|
+
const bucketStorage = await getTestStorage(factory, syncRules);
|
|
130
168
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
131
169
|
|
|
132
170
|
await populate(bucketStorage, 2);
|
|
133
171
|
const { checkpoint } = await bucketStorage.getCheckpoint();
|
|
134
172
|
|
|
135
|
-
//
|
|
136
|
-
|
|
173
|
+
// Force V3's initial lite pass to process these small buckets for cache verification.
|
|
174
|
+
// Earlier storage versions use the default minimum-change threshold.
|
|
175
|
+
const result0 = await bucketStorage.compactInitialReplication({
|
|
176
|
+
forceChunkCompaction: true,
|
|
137
177
|
maxOpId: checkpoint
|
|
138
178
|
});
|
|
139
|
-
expect(result0.buckets).toEqual(0);
|
|
179
|
+
expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
|
|
140
180
|
|
|
141
|
-
//
|
|
142
|
-
|
|
181
|
+
// For V1/V2, lower the threshold to populate the checksum cache. V3 has
|
|
182
|
+
// already updated its compacted state, so another initial pass is a no-op.
|
|
183
|
+
const result1 = await bucketStorage.compactInitialReplication({
|
|
184
|
+
forceChunkCompaction: true,
|
|
143
185
|
maxOpId: checkpoint,
|
|
144
186
|
minBucketChanges: 1
|
|
145
187
|
});
|
|
146
|
-
expect(result1.buckets).toEqual(2);
|
|
188
|
+
expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
|
|
147
189
|
|
|
148
|
-
//
|
|
149
|
-
const result2 = await bucketStorage.
|
|
190
|
+
// Repeating it stays a no-op.
|
|
191
|
+
const result2 = await bucketStorage.compactInitialReplication({
|
|
192
|
+
forceChunkCompaction: true,
|
|
150
193
|
maxOpId: checkpoint,
|
|
151
194
|
minBucketChanges: 1
|
|
152
195
|
});
|
|
@@ -168,88 +211,87 @@ bucket_definitions:
|
|
|
168
211
|
});
|
|
169
212
|
});
|
|
170
213
|
|
|
171
|
-
test('
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
|
|
215
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
216
|
+
const result = await (bucketStorage as MongoSyncBucketStorage)
|
|
217
|
+
.createMongoCompactor({
|
|
218
|
+
maxOpId: checkpoint,
|
|
219
|
+
compactChunksOnly: true,
|
|
220
|
+
forceChunkCompaction: true,
|
|
221
|
+
minCompactChunkIntervalMs: 1
|
|
222
|
+
})
|
|
223
|
+
.compact();
|
|
224
|
+
|
|
225
|
+
expect(result).toBe(2);
|
|
226
|
+
});
|
|
182
227
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await bucketStateCollection.insertOne({
|
|
191
|
-
_id: {
|
|
192
|
-
d: '1',
|
|
193
|
-
b: 'global[]'
|
|
194
|
-
},
|
|
195
|
-
last_op: 5n,
|
|
196
|
-
compacted_state: {
|
|
197
|
-
op_id: 3n,
|
|
198
|
-
count: 3,
|
|
199
|
-
checksum: 0n,
|
|
200
|
-
bytes: 7n
|
|
201
|
-
},
|
|
202
|
-
estimate_since_compact: {
|
|
203
|
-
count: 2,
|
|
204
|
-
bytes: 5n
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
} else {
|
|
208
|
-
await factory.db.bucket_state.insertOne({
|
|
209
|
-
_id: {
|
|
210
|
-
g: bucketStorage.replicationStreamId,
|
|
211
|
-
b: 'global[]'
|
|
212
|
-
},
|
|
213
|
-
last_op: 5n,
|
|
214
|
-
compacted_state: {
|
|
215
|
-
op_id: 3n,
|
|
216
|
-
count: 3,
|
|
217
|
-
checksum: 0n,
|
|
218
|
-
bytes: 7n
|
|
219
|
-
},
|
|
220
|
-
estimate_since_compact: {
|
|
221
|
-
count: 2,
|
|
222
|
-
bytes: 5n
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
}
|
|
228
|
+
test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
|
|
229
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
230
|
+
const firstResult = await bucketStorage.compactInitialReplication({
|
|
231
|
+
forceChunkCompaction: true,
|
|
232
|
+
maxOpId: checkpoint
|
|
233
|
+
});
|
|
234
|
+
expect(firstResult.buckets).toBe(2);
|
|
226
235
|
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
|
|
237
|
+
bucketStorage.replicationStreamId
|
|
238
|
+
);
|
|
239
|
+
await bucketStateCollection.updateMany(
|
|
240
|
+
{},
|
|
241
|
+
{
|
|
242
|
+
$set: {
|
|
243
|
+
first_uncompacted_write: new Date(0),
|
|
244
|
+
next_compact_check: new Date(0)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
);
|
|
229
248
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
249
|
+
const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
|
|
250
|
+
.aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
251
|
+
.toArray();
|
|
252
|
+
const result = await bucketStorage.compactInitialReplication({
|
|
253
|
+
forceChunkCompaction: true,
|
|
254
|
+
maxOpId: checkpoint,
|
|
255
|
+
signal: AbortSignal.timeout(2_000)
|
|
233
256
|
});
|
|
234
|
-
const firstBatch = await dirtyBuckets.next();
|
|
235
257
|
|
|
236
|
-
expect(
|
|
237
|
-
|
|
238
|
-
expect(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
258
|
+
expect(result.buckets).toBe(0);
|
|
259
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
260
|
+
expect(states).toHaveLength(2);
|
|
261
|
+
for (const state of states) {
|
|
262
|
+
expect(state.next_compact_check!.getTime()).toBeGreaterThan(
|
|
263
|
+
now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('v3 replication writes initialize scheduled compaction state', async () => {
|
|
269
|
+
const { bucketStorage } = await setup();
|
|
270
|
+
const storageDb = bucketStorage.db;
|
|
242
271
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
272
|
+
if (!storageDb.storageConfig.incrementalReprocessing) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
|
|
276
|
+
bucketStorage.replicationStreamId
|
|
277
|
+
);
|
|
278
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
279
|
+
expect(states).toHaveLength(2);
|
|
280
|
+
for (const state of states) {
|
|
281
|
+
expect(state.last_op).toBeGreaterThan(0n);
|
|
282
|
+
expect(state.bucket_stats.count).toBe(1);
|
|
283
|
+
expect(state.bucket_stats.chunks).toBe(1);
|
|
284
|
+
expect(state.first_uncompacted_write).toBeInstanceOf(Date);
|
|
285
|
+
expect(state.next_compact_check).toBeInstanceOf(Date);
|
|
286
|
+
expect(state.compacted_state).toBeUndefined();
|
|
287
|
+
expect(state).not.toHaveProperty('estimate_since_compact');
|
|
288
|
+
|
|
289
|
+
const docs = await (storageDb as VersionedPowerSyncMongoV3)
|
|
290
|
+
.bucketData(bucketStorage.replicationStreamId, state._id.d)
|
|
291
|
+
.find({ '_id.b': state._id.b })
|
|
292
|
+
.toArray();
|
|
293
|
+
expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
|
|
294
|
+
}
|
|
253
295
|
});
|
|
254
296
|
});
|
|
255
297
|
});
|
|
@@ -284,14 +326,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
|
|
|
284
326
|
*/
|
|
285
327
|
describe('V3 invariant verification', () => {
|
|
286
328
|
const BUCKET = 'global[]';
|
|
287
|
-
const TABLE = 'items';
|
|
288
329
|
|
|
289
330
|
function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
|
|
331
|
+
const sourceTable = new bson.ObjectId();
|
|
332
|
+
const sourceKey = 'key';
|
|
290
333
|
return {
|
|
291
334
|
bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
|
|
292
335
|
op: 'PUT',
|
|
293
|
-
source_table:
|
|
294
|
-
source_key:
|
|
336
|
+
source_table: sourceTable,
|
|
337
|
+
source_key: sourceKey,
|
|
338
|
+
subkey: replicaIdToSubkey(sourceTable, sourceKey),
|
|
295
339
|
table: 'test',
|
|
296
340
|
row_id: 'row1',
|
|
297
341
|
checksum: 1n,
|
|
@@ -312,7 +356,7 @@ bucket_definitions:
|
|
|
312
356
|
{ storageVersion: 3 }
|
|
313
357
|
)
|
|
314
358
|
);
|
|
315
|
-
const bucketStorage = factory
|
|
359
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
|
|
316
360
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
317
361
|
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
318
362
|
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
@@ -329,31 +373,6 @@ bucket_definitions:
|
|
|
329
373
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
|
|
330
374
|
}
|
|
331
375
|
|
|
332
|
-
function makeOp(
|
|
333
|
-
opId: number,
|
|
334
|
-
rowId: string,
|
|
335
|
-
data: string,
|
|
336
|
-
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
337
|
-
sourceTableId: bson.ObjectId,
|
|
338
|
-
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
339
|
-
): BucketDataDoc {
|
|
340
|
-
return {
|
|
341
|
-
bucketKey: {
|
|
342
|
-
replicationStreamId: ctx.replicationStreamId,
|
|
343
|
-
definitionId: ctx.definitionId,
|
|
344
|
-
bucket: ctx.bucket
|
|
345
|
-
},
|
|
346
|
-
o: BigInt(opId),
|
|
347
|
-
op: overrides?.op ?? 'PUT',
|
|
348
|
-
source_table: sourceTableId,
|
|
349
|
-
source_key: test_utils.rid(rowId),
|
|
350
|
-
table: TABLE,
|
|
351
|
-
row_id: rowId,
|
|
352
|
-
checksum: BigInt(opId * 7),
|
|
353
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
|
|
357
376
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
358
377
|
await collection.insertMany(docs);
|
|
359
378
|
}
|
|
@@ -362,7 +381,9 @@ bucket_definitions:
|
|
|
362
381
|
await bucketStateCollection.insertOne({
|
|
363
382
|
_id: { d: definitionId, b: BUCKET },
|
|
364
383
|
last_op: lastOp,
|
|
365
|
-
|
|
384
|
+
next_compact_check: new Date(0),
|
|
385
|
+
first_uncompacted_write: new Date(0),
|
|
386
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
366
387
|
});
|
|
367
388
|
}
|
|
368
389
|
|
|
@@ -371,12 +392,222 @@ bucket_definitions:
|
|
|
371
392
|
clearBatchLimit: 200,
|
|
372
393
|
moveBatchLimit: 10,
|
|
373
394
|
moveBatchQueryLimit: 10,
|
|
374
|
-
minBucketChanges: 1,
|
|
375
|
-
minChangeRatio: 0,
|
|
376
395
|
maxOpId
|
|
377
396
|
});
|
|
378
397
|
}
|
|
379
398
|
|
|
399
|
+
test.each([1, 7])(
|
|
400
|
+
'initial compaction bulk-reschedules buckets with %s chunks without reading data',
|
|
401
|
+
async (chunks) => {
|
|
402
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
|
|
403
|
+
const documents = Array.from({ length: chunks }, (_, index) =>
|
|
404
|
+
serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
|
|
405
|
+
);
|
|
406
|
+
await insertDocs(collection, documents);
|
|
407
|
+
await bucketStateCollection.insertOne({
|
|
408
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
409
|
+
last_op: BigInt(chunks),
|
|
410
|
+
next_compact_check: new Date(0),
|
|
411
|
+
// Even overdue full compaction must not force a small initial chunk merge.
|
|
412
|
+
first_uncompacted_write: new Date(0),
|
|
413
|
+
bucket_stats: { count: chunks, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks }
|
|
414
|
+
});
|
|
415
|
+
const claim = vi.spyOn(CompactionLease, 'claim');
|
|
416
|
+
const data = vi.spyOn(db, 'bucketData');
|
|
417
|
+
try {
|
|
418
|
+
expect(await bucketStorage.compactInitialReplication({ maxOpId: BigInt(chunks) })).toEqual({ buckets: 0 });
|
|
419
|
+
expect(claim).not.toHaveBeenCalled();
|
|
420
|
+
expect(data).not.toHaveBeenCalled();
|
|
421
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
422
|
+
expect(state?.compacted_state).toBeUndefined();
|
|
423
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
424
|
+
expect(state?.last_full_compact).toBeUndefined();
|
|
425
|
+
expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
426
|
+
expect(await collection.find().toArray()).toEqual(documents);
|
|
427
|
+
} finally {
|
|
428
|
+
claim.mockRestore();
|
|
429
|
+
data.mockRestore();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
test('initial compaction rechecks the chunk threshold after claiming', async () => {
|
|
435
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
|
|
436
|
+
const documents = Array.from({ length: 8 }, (_, index) =>
|
|
437
|
+
serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
|
|
438
|
+
);
|
|
439
|
+
await insertDocs(collection, documents);
|
|
440
|
+
const bytes = BigInt(documents.reduce((sum, doc) => sum + doc.size, 0));
|
|
441
|
+
await bucketStateCollection.insertOne({
|
|
442
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
443
|
+
last_op: 8n,
|
|
444
|
+
next_compact_check: new Date(0),
|
|
445
|
+
first_uncompacted_write: new Date(),
|
|
446
|
+
bucket_stats: { count: 8, bytes, chunks: 8 }
|
|
447
|
+
});
|
|
448
|
+
const originalClaim = CompactionLease.claim.bind(CompactionLease);
|
|
449
|
+
const claim = vi.spyOn(CompactionLease, 'claim').mockImplementationOnce(async (...args) => {
|
|
450
|
+
// Another compactor finishes before we acquire the lease, leaving one
|
|
451
|
+
// new chunk beyond its cached prefix instead of the eight we selected.
|
|
452
|
+
await bucketStateCollection.updateOne(
|
|
453
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
454
|
+
{
|
|
455
|
+
$set: {
|
|
456
|
+
compacted_state: {
|
|
457
|
+
op_id: 7n,
|
|
458
|
+
checksum: 196n,
|
|
459
|
+
count: 7,
|
|
460
|
+
bytes: bytes - BigInt(documents[7].size),
|
|
461
|
+
chunks: 7,
|
|
462
|
+
at: new Date()
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
return originalClaim(...args);
|
|
468
|
+
});
|
|
469
|
+
const data = vi.spyOn(db, 'bucketData');
|
|
470
|
+
try {
|
|
471
|
+
expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 0 });
|
|
472
|
+
expect(claim).toHaveBeenCalledTimes(1);
|
|
473
|
+
expect(data).not.toHaveBeenCalled();
|
|
474
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
475
|
+
expect(state?.compacted_state?.op_id).toBe(7n);
|
|
476
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
477
|
+
expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
478
|
+
} finally {
|
|
479
|
+
claim.mockRestore();
|
|
480
|
+
data.mockRestore();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('a successful lease renewal clears a transient renewal error', async () => {
|
|
485
|
+
const { bucketStateCollection, ctx } = await setupV3Storage();
|
|
486
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
|
|
487
|
+
const lease = await CompactionLease.claim(
|
|
488
|
+
bucketStateCollection,
|
|
489
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
490
|
+
undefined,
|
|
491
|
+
10 * 60 * 1000
|
|
492
|
+
);
|
|
493
|
+
expect(lease).not.toBeNull();
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const transientError = new Error('temporary MongoDB error');
|
|
497
|
+
const renew = (lease as any).renew.bind(lease);
|
|
498
|
+
const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
|
|
499
|
+
await renew().catch((error: unknown) => {
|
|
500
|
+
(lease as any).renewalError = error;
|
|
501
|
+
});
|
|
502
|
+
await expect(lease!.throwIfLost()).rejects.toBe(transientError);
|
|
503
|
+
|
|
504
|
+
updateOne.mockRestore();
|
|
505
|
+
await renew();
|
|
506
|
+
await expect(lease!.throwIfLost()).resolves.toBeUndefined();
|
|
507
|
+
} finally {
|
|
508
|
+
await lease?.[Symbol.asyncDispose]();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('a failed scheduled bucket does not block other scheduled buckets', async () => {
|
|
513
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
514
|
+
const badBucket = 'bad[]';
|
|
515
|
+
const goodBucket = 'good[]';
|
|
516
|
+
const goodDocument = serializeBucketData(goodBucket, [
|
|
517
|
+
makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
|
|
518
|
+
]);
|
|
519
|
+
await insertDocs(collection, [goodDocument]);
|
|
520
|
+
await bucketStateCollection.insertMany([
|
|
521
|
+
{
|
|
522
|
+
_id: { d: ctx.definitionId, b: badBucket },
|
|
523
|
+
last_op: 2n,
|
|
524
|
+
next_compact_check: new Date(0),
|
|
525
|
+
// A malformed scheduled bucket with an expired lease must be
|
|
526
|
+
// rescheduled without preventing valid buckets from compacting.
|
|
527
|
+
first_uncompacted_write: new Date(0),
|
|
528
|
+
bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
|
|
529
|
+
compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
_id: { d: ctx.definitionId, b: goodBucket },
|
|
533
|
+
last_op: 2n,
|
|
534
|
+
next_compact_check: new Date(0),
|
|
535
|
+
first_uncompacted_write: new Date(0),
|
|
536
|
+
bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
|
|
537
|
+
}
|
|
538
|
+
]);
|
|
539
|
+
await bucketStateCollection.updateOne(
|
|
540
|
+
{ _id: { d: ctx.definitionId, b: badBucket } },
|
|
541
|
+
{ $unset: { first_uncompacted_write: '' } }
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
await (bucketStorage as MongoSyncBucketStorage)
|
|
545
|
+
.createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true, forceChunkCompaction: true })
|
|
546
|
+
.compact();
|
|
547
|
+
|
|
548
|
+
const [badState, goodState] = await Promise.all([
|
|
549
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
|
|
550
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
|
|
551
|
+
]);
|
|
552
|
+
expect(badState?.next_compact_check).toBeInstanceOf(Date);
|
|
553
|
+
expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
554
|
+
expect(goodState?.compacted_state?.op_id).toBe(2n);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
|
|
558
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
559
|
+
const buckets = Array.from({ length: 8 }, (_, index) => `bucket${index}[]`);
|
|
560
|
+
const documents = buckets.map((bucket) =>
|
|
561
|
+
serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
|
|
562
|
+
);
|
|
563
|
+
await insertDocs(collection, documents);
|
|
564
|
+
await bucketStateCollection.insertMany(
|
|
565
|
+
documents.map((document, index) => ({
|
|
566
|
+
_id: { d: ctx.definitionId, b: buckets[index] },
|
|
567
|
+
last_op: 2n,
|
|
568
|
+
next_compact_check: new Date(0),
|
|
569
|
+
first_uncompacted_write: new Date(0),
|
|
570
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
571
|
+
}))
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const abortController = new AbortController();
|
|
575
|
+
const testLogger = defaultLogger.child({});
|
|
576
|
+
vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
|
|
577
|
+
if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
|
|
578
|
+
abortController.abort();
|
|
579
|
+
}
|
|
580
|
+
return testLogger;
|
|
581
|
+
});
|
|
582
|
+
const errorLog = vi.spyOn(testLogger, 'error');
|
|
583
|
+
|
|
584
|
+
await expect(
|
|
585
|
+
bucketStorage
|
|
586
|
+
.createMongoCompactor({
|
|
587
|
+
maxOpId: 2n,
|
|
588
|
+
compactChunksOnly: true,
|
|
589
|
+
forceChunkCompaction: true,
|
|
590
|
+
signal: abortController.signal,
|
|
591
|
+
logger: testLogger
|
|
592
|
+
})
|
|
593
|
+
.compact()
|
|
594
|
+
).rejects.toThrow();
|
|
595
|
+
|
|
596
|
+
const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
|
|
597
|
+
const completed = states.filter((state) => state.compacted_state != null);
|
|
598
|
+
const remaining = states.filter((state) => state.compacted_state == null);
|
|
599
|
+
// Already-started buckets may finish after abort, but the queued remainder
|
|
600
|
+
// must not be claimed or rescheduled.
|
|
601
|
+
expect(completed.length).toBeGreaterThanOrEqual(1);
|
|
602
|
+
expect(completed.length).toBeLessThanOrEqual(2);
|
|
603
|
+
expect(remaining.length).toBeGreaterThanOrEqual(6);
|
|
604
|
+
for (const state of remaining) {
|
|
605
|
+
expect(state.next_compact_check).toEqual(new Date(0));
|
|
606
|
+
expect(state.compact_lease).toBeUndefined();
|
|
607
|
+
}
|
|
608
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
609
|
+
});
|
|
610
|
+
|
|
380
611
|
test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
|
|
381
612
|
const ops = [
|
|
382
613
|
makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
|
|
@@ -746,7 +977,6 @@ bucket_definitions:
|
|
|
746
977
|
|
|
747
978
|
describe('V3 checksum pipeline straddling', () => {
|
|
748
979
|
const BUCKET = 'global[]';
|
|
749
|
-
const TABLE = 'items';
|
|
750
980
|
|
|
751
981
|
async function setup() {
|
|
752
982
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -760,7 +990,7 @@ bucket_definitions:
|
|
|
760
990
|
{ storageVersion: 3 }
|
|
761
991
|
)
|
|
762
992
|
);
|
|
763
|
-
const bucketStorage = factory
|
|
993
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
|
|
764
994
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
765
995
|
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
766
996
|
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
@@ -776,30 +1006,6 @@ bucket_definitions:
|
|
|
776
1006
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
|
|
777
1007
|
}
|
|
778
1008
|
|
|
779
|
-
function makeOp(
|
|
780
|
-
opId: number,
|
|
781
|
-
rowId: string,
|
|
782
|
-
data: string,
|
|
783
|
-
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
784
|
-
sourceTableId: bson.ObjectId
|
|
785
|
-
): BucketDataDoc {
|
|
786
|
-
return {
|
|
787
|
-
bucketKey: {
|
|
788
|
-
replicationStreamId: ctx.replicationStreamId,
|
|
789
|
-
definitionId: ctx.definitionId,
|
|
790
|
-
bucket: ctx.bucket
|
|
791
|
-
},
|
|
792
|
-
o: BigInt(opId),
|
|
793
|
-
op: 'PUT',
|
|
794
|
-
source_table: sourceTableId,
|
|
795
|
-
source_key: test_utils.rid(rowId),
|
|
796
|
-
table: TABLE,
|
|
797
|
-
row_id: rowId,
|
|
798
|
-
checksum: BigInt(opId * 7),
|
|
799
|
-
data: JSON.stringify({ id: rowId, description: data })
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
|
|
803
1009
|
function checksumRequest(): storage.BucketChecksumRequest {
|
|
804
1010
|
return {
|
|
805
1011
|
bucket: BUCKET,
|
|
@@ -835,7 +1041,9 @@ bucket_definitions:
|
|
|
835
1041
|
await bucketStateCollection.insertOne({
|
|
836
1042
|
_id: { d: definitionId, b: BUCKET },
|
|
837
1043
|
last_op: 30n,
|
|
838
|
-
|
|
1044
|
+
next_compact_check: undefined,
|
|
1045
|
+
first_uncompacted_write: undefined,
|
|
1046
|
+
bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
|
|
839
1047
|
});
|
|
840
1048
|
|
|
841
1049
|
const request = checksumRequest();
|
|
@@ -874,7 +1082,9 @@ bucket_definitions:
|
|
|
874
1082
|
await bucketStateCollection.insertOne({
|
|
875
1083
|
_id: { d: definitionId, b: BUCKET },
|
|
876
1084
|
last_op: 0n,
|
|
877
|
-
|
|
1085
|
+
next_compact_check: undefined,
|
|
1086
|
+
first_uncompacted_write: undefined,
|
|
1087
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
878
1088
|
});
|
|
879
1089
|
|
|
880
1090
|
const request = checksumRequest();
|
|
@@ -894,7 +1104,9 @@ bucket_definitions:
|
|
|
894
1104
|
await bucketStateCollection.insertOne({
|
|
895
1105
|
_id: { d: definitionId, b: BUCKET },
|
|
896
1106
|
last_op: 0n,
|
|
897
|
-
|
|
1107
|
+
next_compact_check: undefined,
|
|
1108
|
+
first_uncompacted_write: undefined,
|
|
1109
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
898
1110
|
});
|
|
899
1111
|
|
|
900
1112
|
const request = checksumRequest();
|
|
@@ -913,7 +1125,9 @@ bucket_definitions:
|
|
|
913
1125
|
await bucketStateCollection.insertOne({
|
|
914
1126
|
_id: { d: definitionId, b: BUCKET },
|
|
915
1127
|
last_op: 10n,
|
|
916
|
-
|
|
1128
|
+
next_compact_check: undefined,
|
|
1129
|
+
first_uncompacted_write: undefined,
|
|
1130
|
+
bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
|
|
917
1131
|
});
|
|
918
1132
|
|
|
919
1133
|
const request = checksumRequest();
|
|
@@ -926,7 +1140,7 @@ bucket_definitions:
|
|
|
926
1140
|
await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
|
|
927
1141
|
await bucketStateCollection.updateOne(
|
|
928
1142
|
{ _id: { d: definitionId, b: BUCKET } },
|
|
929
|
-
{ $set: { last_op: 30n, '
|
|
1143
|
+
{ $set: { last_op: 30n, 'bucket_stats.count': 2 } }
|
|
930
1144
|
);
|
|
931
1145
|
|
|
932
1146
|
const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
@@ -940,7 +1154,6 @@ bucket_definitions:
|
|
|
940
1154
|
|
|
941
1155
|
describe('V3 compaction boundaries', () => {
|
|
942
1156
|
const BUCKET = 'global[]';
|
|
943
|
-
const TABLE = 'items';
|
|
944
1157
|
|
|
945
1158
|
async function setupV3() {
|
|
946
1159
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -954,7 +1167,7 @@ bucket_definitions:
|
|
|
954
1167
|
{ storageVersion: 3 }
|
|
955
1168
|
)
|
|
956
1169
|
);
|
|
957
|
-
const bucketStorage = factory
|
|
1170
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
|
|
958
1171
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
959
1172
|
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
960
1173
|
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
@@ -971,31 +1184,6 @@ bucket_definitions:
|
|
|
971
1184
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
972
1185
|
}
|
|
973
1186
|
|
|
974
|
-
function makeOp(
|
|
975
|
-
opId: number,
|
|
976
|
-
rowId: string,
|
|
977
|
-
data: string,
|
|
978
|
-
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
979
|
-
sourceTableId: bson.ObjectId,
|
|
980
|
-
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
981
|
-
): BucketDataDoc {
|
|
982
|
-
return {
|
|
983
|
-
bucketKey: {
|
|
984
|
-
replicationStreamId: ctx.replicationStreamId,
|
|
985
|
-
definitionId: ctx.definitionId,
|
|
986
|
-
bucket: ctx.bucket
|
|
987
|
-
},
|
|
988
|
-
o: BigInt(opId),
|
|
989
|
-
op: overrides?.op ?? 'PUT',
|
|
990
|
-
source_table: sourceTableId,
|
|
991
|
-
source_key: test_utils.rid(rowId),
|
|
992
|
-
table: TABLE,
|
|
993
|
-
row_id: rowId,
|
|
994
|
-
checksum: BigInt(opId * 7),
|
|
995
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
|
|
999
1187
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1000
1188
|
await collection.insertMany(docs);
|
|
1001
1189
|
}
|
|
@@ -1004,7 +1192,9 @@ bucket_definitions:
|
|
|
1004
1192
|
await bucketStateCollection.insertOne({
|
|
1005
1193
|
_id: { d: definitionId, b: BUCKET },
|
|
1006
1194
|
last_op: lastOp,
|
|
1007
|
-
|
|
1195
|
+
next_compact_check: new Date(0),
|
|
1196
|
+
first_uncompacted_write: new Date(0),
|
|
1197
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1008
1198
|
});
|
|
1009
1199
|
}
|
|
1010
1200
|
|
|
@@ -1030,6 +1220,156 @@ bucket_definitions:
|
|
|
1030
1220
|
return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1031
1221
|
}
|
|
1032
1222
|
|
|
1223
|
+
test('scheduled compaction claims only due buckets and clears completed full work', async () => {
|
|
1224
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1225
|
+
const docs = [
|
|
1226
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
|
|
1227
|
+
serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
|
|
1228
|
+
];
|
|
1229
|
+
await insertDocs(collection, docs);
|
|
1230
|
+
await bucketStateCollection.insertOne({
|
|
1231
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1232
|
+
last_op: 2n,
|
|
1233
|
+
next_compact_check: new Date(Date.now() + 60_000),
|
|
1234
|
+
first_uncompacted_write: new Date(Date.now() - 60_000),
|
|
1235
|
+
bucket_stats: {
|
|
1236
|
+
count: 2,
|
|
1237
|
+
bytes: BigInt(docs[0].size + docs[1].size),
|
|
1238
|
+
chunks: 2
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1243
|
+
expect(
|
|
1244
|
+
(await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
|
|
1245
|
+
).toBeUndefined();
|
|
1246
|
+
|
|
1247
|
+
await bucketStateCollection.updateOne(
|
|
1248
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1249
|
+
{ $set: { next_compact_check: new Date(Date.now() - 1) } }
|
|
1250
|
+
);
|
|
1251
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1252
|
+
|
|
1253
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1254
|
+
expect(state?.last_full_compact?.op_id).toBe(2n);
|
|
1255
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1256
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1257
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
|
|
1261
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1262
|
+
const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
|
|
1263
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
|
|
1264
|
+
await insertDocs(collection, [prefix, untouchedTail]);
|
|
1265
|
+
await bucketStateCollection.insertOne({
|
|
1266
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1267
|
+
last_op: 2n,
|
|
1268
|
+
next_compact_check: new Date(0),
|
|
1269
|
+
first_uncompacted_write: new Date(0),
|
|
1270
|
+
bucket_stats: {
|
|
1271
|
+
count: prefix.count + untouchedTail.count,
|
|
1272
|
+
bytes: BigInt(prefix.size + untouchedTail.size),
|
|
1273
|
+
chunks: 2
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1278
|
+
|
|
1279
|
+
const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1280
|
+
expect(partialState?.last_full_compact).toMatchObject({
|
|
1281
|
+
op_id: 1n,
|
|
1282
|
+
count: prefix.count,
|
|
1283
|
+
puts: 1
|
|
1284
|
+
});
|
|
1285
|
+
expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
|
|
1286
|
+
expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
|
|
1287
|
+
expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
|
|
1288
|
+
partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
const lastFullCompactAt = partialState!.last_full_compact!.at;
|
|
1292
|
+
const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
|
|
1293
|
+
await bucketStateCollection.updateOne(
|
|
1294
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1295
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
await bucketStorage.compact({ maxOpId: 2n });
|
|
1299
|
+
|
|
1300
|
+
const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1301
|
+
expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1302
|
+
expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
|
|
1303
|
+
expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
1304
|
+
|
|
1305
|
+
// Even after the scheduling window has elapsed, a run with the old cap
|
|
1306
|
+
// must not scan and record the same full-compaction prefix again.
|
|
1307
|
+
await bucketStateCollection.updateOne(
|
|
1308
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1309
|
+
{ $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1313
|
+
|
|
1314
|
+
const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1315
|
+
expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1316
|
+
expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
|
|
1317
|
+
expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
|
|
1321
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1322
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1323
|
+
await insertDocs(collection, [document]);
|
|
1324
|
+
await bucketStateCollection.insertOne({
|
|
1325
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1326
|
+
last_op: 1n,
|
|
1327
|
+
next_compact_check: undefined,
|
|
1328
|
+
first_uncompacted_write: undefined,
|
|
1329
|
+
compacted_state: {
|
|
1330
|
+
op_id: 1n,
|
|
1331
|
+
checksum: document.checksum,
|
|
1332
|
+
count: document.count,
|
|
1333
|
+
bytes: BigInt(document.size),
|
|
1334
|
+
chunks: 1,
|
|
1335
|
+
at: new Date()
|
|
1336
|
+
},
|
|
1337
|
+
last_full_compact: {
|
|
1338
|
+
op_id: 1n,
|
|
1339
|
+
count: document.count,
|
|
1340
|
+
puts: 1,
|
|
1341
|
+
at: new Date()
|
|
1342
|
+
},
|
|
1343
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
|
|
1347
|
+
|
|
1348
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1349
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1350
|
+
expect(state?.next_compact_check).toBeNull();
|
|
1351
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
test('concurrent scheduled compactors lease a bucket to one worker', async () => {
|
|
1355
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1356
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1357
|
+
await insertDocs(collection, [document]);
|
|
1358
|
+
await bucketStateCollection.insertOne({
|
|
1359
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1360
|
+
last_op: 1n,
|
|
1361
|
+
next_compact_check: new Date(Date.now() - 1),
|
|
1362
|
+
first_uncompacted_write: new Date(0),
|
|
1363
|
+
bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
|
|
1367
|
+
|
|
1368
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1369
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1370
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1033
1373
|
test('1. superseded ops become MOVE tombstones', async () => {
|
|
1034
1374
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1035
1375
|
// Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
|
|
@@ -1207,7 +1547,6 @@ bucket_definitions:
|
|
|
1207
1547
|
|
|
1208
1548
|
describe('V3 MOVE tombstone properties', () => {
|
|
1209
1549
|
const BUCKET = 'global[]';
|
|
1210
|
-
const TABLE = 'items';
|
|
1211
1550
|
|
|
1212
1551
|
async function setupV3() {
|
|
1213
1552
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -1221,7 +1560,7 @@ bucket_definitions:
|
|
|
1221
1560
|
{ storageVersion: 3 }
|
|
1222
1561
|
)
|
|
1223
1562
|
);
|
|
1224
|
-
const bucketStorage = factory
|
|
1563
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
|
|
1225
1564
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
1226
1565
|
const mapping = syncRules.syncConfigContent[0].mapping;
|
|
1227
1566
|
const definitionId = mapping.allBucketDefinitionIds()[0];
|
|
@@ -1238,31 +1577,6 @@ bucket_definitions:
|
|
|
1238
1577
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1239
1578
|
}
|
|
1240
1579
|
|
|
1241
|
-
function makeOp(
|
|
1242
|
-
opId: number,
|
|
1243
|
-
rowId: string,
|
|
1244
|
-
data: string,
|
|
1245
|
-
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
1246
|
-
sourceTableId: bson.ObjectId,
|
|
1247
|
-
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
1248
|
-
): BucketDataDoc {
|
|
1249
|
-
return {
|
|
1250
|
-
bucketKey: {
|
|
1251
|
-
replicationStreamId: ctx.replicationStreamId,
|
|
1252
|
-
definitionId: ctx.definitionId,
|
|
1253
|
-
bucket: ctx.bucket
|
|
1254
|
-
},
|
|
1255
|
-
o: BigInt(opId),
|
|
1256
|
-
op: overrides?.op ?? 'PUT',
|
|
1257
|
-
source_table: sourceTableId,
|
|
1258
|
-
source_key: test_utils.rid(rowId),
|
|
1259
|
-
table: TABLE,
|
|
1260
|
-
row_id: rowId,
|
|
1261
|
-
checksum: BigInt(opId * 7),
|
|
1262
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
1580
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1267
1581
|
await collection.insertMany(docs);
|
|
1268
1582
|
}
|
|
@@ -1271,7 +1585,9 @@ bucket_definitions:
|
|
|
1271
1585
|
await bucketStateCollection.insertOne({
|
|
1272
1586
|
_id: { d: definitionId, b: BUCKET },
|
|
1273
1587
|
last_op: lastOp,
|
|
1274
|
-
|
|
1588
|
+
next_compact_check: new Date(0),
|
|
1589
|
+
first_uncompacted_write: new Date(0),
|
|
1590
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1275
1591
|
});
|
|
1276
1592
|
}
|
|
1277
1593
|
|
|
@@ -1420,10 +1736,9 @@ bucket_definitions:
|
|
|
1420
1736
|
*/
|
|
1421
1737
|
describe('Streaming compactor', () => {
|
|
1422
1738
|
const BUCKET = 'global[]';
|
|
1423
|
-
const TABLE = 'items';
|
|
1424
1739
|
|
|
1425
|
-
async function setupV3() {
|
|
1426
|
-
await using factory = await
|
|
1740
|
+
async function setupV3(factoryGenerator = INITIALIZED_MONGO_STORAGE_FACTORY) {
|
|
1741
|
+
await using factory = await factoryGenerator.factory();
|
|
1427
1742
|
const syncRules = await factory.updateSyncRules(
|
|
1428
1743
|
updateSyncRulesFromYaml(
|
|
1429
1744
|
`
|
|
@@ -1434,7 +1749,7 @@ bucket_definitions:
|
|
|
1434
1749
|
{ storageVersion: 3 }
|
|
1435
1750
|
)
|
|
1436
1751
|
);
|
|
1437
|
-
const bucketStorage = factory
|
|
1752
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
|
|
1438
1753
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|
|
1439
1754
|
const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
|
|
1440
1755
|
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
@@ -1450,31 +1765,6 @@ bucket_definitions:
|
|
|
1450
1765
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1451
1766
|
}
|
|
1452
1767
|
|
|
1453
|
-
function makeOp(
|
|
1454
|
-
opId: number,
|
|
1455
|
-
rowId: string,
|
|
1456
|
-
data: string,
|
|
1457
|
-
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
1458
|
-
sourceTableId: bson.ObjectId,
|
|
1459
|
-
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
1460
|
-
): BucketDataDoc {
|
|
1461
|
-
return {
|
|
1462
|
-
bucketKey: {
|
|
1463
|
-
replicationStreamId: ctx.replicationStreamId,
|
|
1464
|
-
definitionId: ctx.definitionId,
|
|
1465
|
-
bucket: ctx.bucket
|
|
1466
|
-
},
|
|
1467
|
-
o: BigInt(opId),
|
|
1468
|
-
op: overrides?.op ?? 'PUT',
|
|
1469
|
-
source_table: sourceTableId,
|
|
1470
|
-
source_key: test_utils.rid(rowId),
|
|
1471
|
-
table: TABLE,
|
|
1472
|
-
row_id: rowId,
|
|
1473
|
-
checksum: BigInt(opId * 7),
|
|
1474
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1475
|
-
};
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
1768
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1479
1769
|
await collection.insertMany(docs);
|
|
1480
1770
|
}
|
|
@@ -1483,7 +1773,9 @@ bucket_definitions:
|
|
|
1483
1773
|
await bucketStateCollection.insertOne({
|
|
1484
1774
|
_id: { d: definitionId, b: BUCKET },
|
|
1485
1775
|
last_op: lastOp,
|
|
1486
|
-
|
|
1776
|
+
next_compact_check: new Date(0),
|
|
1777
|
+
first_uncompacted_write: new Date(0),
|
|
1778
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1487
1779
|
});
|
|
1488
1780
|
}
|
|
1489
1781
|
|
|
@@ -1501,6 +1793,551 @@ bucket_definitions:
|
|
|
1501
1793
|
);
|
|
1502
1794
|
}
|
|
1503
1795
|
|
|
1796
|
+
async function setupCompactedTail() {
|
|
1797
|
+
const setup = await setupV3();
|
|
1798
|
+
const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
|
|
1799
|
+
const documents = [
|
|
1800
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1801
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1802
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1803
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1804
|
+
];
|
|
1805
|
+
await insertDocs(collection, documents);
|
|
1806
|
+
await bucketStateCollection.insertOne({
|
|
1807
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1808
|
+
last_op: 4n,
|
|
1809
|
+
next_compact_check: new Date(0),
|
|
1810
|
+
first_uncompacted_write: new Date(0),
|
|
1811
|
+
compacted_state: {
|
|
1812
|
+
op_id: 2n,
|
|
1813
|
+
checksum: documents[0].checksum + documents[1].checksum,
|
|
1814
|
+
count: documents[0].count + documents[1].count,
|
|
1815
|
+
bytes: BigInt(documents[0].size + documents[1].size),
|
|
1816
|
+
chunks: 2,
|
|
1817
|
+
at: new Date(0)
|
|
1818
|
+
},
|
|
1819
|
+
bucket_stats: {
|
|
1820
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1821
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1822
|
+
chunks: documents.length
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
return setup;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
|
|
1829
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1830
|
+
expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
|
|
1831
|
+
1n,
|
|
1832
|
+
2n,
|
|
1833
|
+
3n,
|
|
1834
|
+
4n
|
|
1835
|
+
]);
|
|
1836
|
+
|
|
1837
|
+
const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
|
|
1838
|
+
const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
|
|
1839
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1840
|
+
op_id: 4n,
|
|
1841
|
+
checksum: 70n,
|
|
1842
|
+
count: 4,
|
|
1843
|
+
bytes,
|
|
1844
|
+
chunks: documents.length
|
|
1845
|
+
});
|
|
1846
|
+
expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
test('initial compaction merges eight chunks immediately without full compaction', async () => {
|
|
1850
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1851
|
+
const inputs = Array.from({ length: 8 }, (_, index) =>
|
|
1852
|
+
serializeBucketData(BUCKET, [makeOp(index + 1, 'A', 'data', ctx, sourceTableId)])
|
|
1853
|
+
);
|
|
1854
|
+
await insertDocs(collection, inputs);
|
|
1855
|
+
await bucketStateCollection.insertOne({
|
|
1856
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1857
|
+
last_op: 8n,
|
|
1858
|
+
next_compact_check: new Date(),
|
|
1859
|
+
first_uncompacted_write: new Date(0),
|
|
1860
|
+
bucket_stats: { count: 8, bytes: BigInt(inputs.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 1 });
|
|
1864
|
+
const documents = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
1865
|
+
expect(documents).toHaveLength(1);
|
|
1866
|
+
expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n]);
|
|
1867
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1868
|
+
expect(state?.compacted_state).toMatchObject({ op_id: 8n, count: 8, checksum: 252n });
|
|
1869
|
+
expect(state?.last_full_compact).toBeUndefined();
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
|
|
1873
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1874
|
+
await insertDocs(collection, [
|
|
1875
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1876
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1877
|
+
]);
|
|
1878
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
|
|
1879
|
+
|
|
1880
|
+
const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
|
|
1881
|
+
|
|
1882
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1883
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1884
|
+
expect(documents).toHaveLength(1);
|
|
1885
|
+
expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
|
|
1886
|
+
|
|
1887
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1888
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1889
|
+
op_id: 4n,
|
|
1890
|
+
count: 4,
|
|
1891
|
+
checksum: 70n
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
test.each([
|
|
1896
|
+
{ payloadSize: 100, workers: undefined, abort: false },
|
|
1897
|
+
{ payloadSize: 450_000, workers: undefined, abort: false },
|
|
1898
|
+
{ payloadSize: 450_000, workers: 4, abort: false },
|
|
1899
|
+
{ payloadSize: 100, workers: 1, abort: false },
|
|
1900
|
+
{ payloadSize: 450_000, workers: 4, abort: true }
|
|
1901
|
+
])('chunk compaction shares configured workers across runs: %o', async ({ payloadSize, workers, abort }) => {
|
|
1902
|
+
const concurrency = workers ?? 4;
|
|
1903
|
+
const objectStorage = new MemoryObjectStorage();
|
|
1904
|
+
const generator = mongoTestStorageFactoryGenerator({
|
|
1905
|
+
url: env.MONGO_TEST_URL,
|
|
1906
|
+
isCI: env.CI,
|
|
1907
|
+
objectStorage,
|
|
1908
|
+
chunkCompactionConcurrency: workers,
|
|
1909
|
+
inlineThresholdBytes: 0
|
|
1910
|
+
});
|
|
1911
|
+
const { bucketStorage, syncRules, collection, bucketStateCollection, ctx, sourceTableId, db } =
|
|
1912
|
+
await setupV3(generator);
|
|
1913
|
+
const bucketCount = 12;
|
|
1914
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
1915
|
+
const bucket = `global[${i}]`;
|
|
1916
|
+
const documents = [1, 2].map((id) =>
|
|
1917
|
+
serializeBucketData(bucket, [makeOp(id, String(id), 'x'.repeat(payloadSize), ctx, sourceTableId)])
|
|
1918
|
+
);
|
|
1919
|
+
await collection.insertMany(documents);
|
|
1920
|
+
await bucketStateCollection.insertOne({
|
|
1921
|
+
_id: { d: ctx.definitionId, b: bucket },
|
|
1922
|
+
last_op: 2n,
|
|
1923
|
+
next_compact_check: new Date(0),
|
|
1924
|
+
first_uncompacted_write: new Date(0),
|
|
1925
|
+
bucket_stats: {
|
|
1926
|
+
count: 2,
|
|
1927
|
+
bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)),
|
|
1928
|
+
chunks: 2
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
const gate = Promise.withResolvers<void>();
|
|
1934
|
+
const originalPut = objectStorage.put.bind(objectStorage);
|
|
1935
|
+
let uploads = 0;
|
|
1936
|
+
const put = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
|
|
1937
|
+
uploads++;
|
|
1938
|
+
await gate.promise;
|
|
1939
|
+
await originalPut(...args);
|
|
1940
|
+
});
|
|
1941
|
+
const controller = new AbortController();
|
|
1942
|
+
let settled = false;
|
|
1943
|
+
const runs = Promise.allSettled(
|
|
1944
|
+
Array.from({ length: 2 }, () =>
|
|
1945
|
+
bucketStorage.factory
|
|
1946
|
+
.getInstance(syncRules)
|
|
1947
|
+
.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n, signal: controller.signal })
|
|
1948
|
+
)
|
|
1949
|
+
).finally(() => {
|
|
1950
|
+
settled = true;
|
|
1951
|
+
});
|
|
1952
|
+
try {
|
|
1953
|
+
await vi.waitFor(() => expect(uploads).toBe(concurrency));
|
|
1954
|
+
// Let other ready workers reach admission; the held uploads must prevent
|
|
1955
|
+
// any additional payloads from reaching S3, across both compactor instances.
|
|
1956
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1957
|
+
expect(uploads).toBe(concurrency);
|
|
1958
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBeLessThanOrEqual(
|
|
1959
|
+
concurrency
|
|
1960
|
+
);
|
|
1961
|
+
expect(await collection.countDocuments()).toBe(bucketCount * 2);
|
|
1962
|
+
if (abort) {
|
|
1963
|
+
controller.abort();
|
|
1964
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1965
|
+
expect(settled).toBe(false);
|
|
1966
|
+
}
|
|
1967
|
+
} finally {
|
|
1968
|
+
gate.resolve();
|
|
1969
|
+
await runs;
|
|
1970
|
+
put.mockRestore();
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const results = await runs;
|
|
1974
|
+
if (abort) {
|
|
1975
|
+
expect(results.every((result) => result.status === 'rejected')).toBe(true);
|
|
1976
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
|
|
1977
|
+
// Cancellation must return all worker slots.
|
|
1978
|
+
expect(await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n })).toEqual({
|
|
1979
|
+
buckets: bucketCount
|
|
1980
|
+
});
|
|
1981
|
+
} else {
|
|
1982
|
+
expect(results.every((result) => result.status === 'fulfilled')).toBe(true);
|
|
1983
|
+
expect(results.reduce((sum, result) => sum + (result.status === 'fulfilled' ? result.value.buckets : 0), 0)).toBe(
|
|
1984
|
+
bucketCount
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const documents = await collection.find().toArray();
|
|
1989
|
+
expect(documents).toHaveLength(bucketCount);
|
|
1990
|
+
expect(documents.every((doc) => doc.count === 2 && doc.checksum === 21n && doc.storage_ref != null)).toBe(true);
|
|
1991
|
+
expect(
|
|
1992
|
+
await bucketStateCollection.countDocuments({ 'compacted_state.op_id': 2n, compact_lease: { $exists: false } })
|
|
1993
|
+
).toBe(bucketCount);
|
|
1994
|
+
const usage = await db.objectStorageUsage.find({ '_id.g': ctx.replicationStreamId }).toArray();
|
|
1995
|
+
const activeBytes = usage.reduce((sum, doc) => sum + BigInt(doc.definitions[ctx.definitionId] ?? 0), 0n);
|
|
1996
|
+
expect(activeBytes).toBe(documents.reduce((sum, doc) => sum + BigInt(doc.storage_ref!.file_size), 0n));
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
test.each([2, 3])('failed chunk worker stops queued work and drains siblings (%s workers)', async (workerCount) => {
|
|
2000
|
+
const { bucketStorage } = await setupV3();
|
|
2001
|
+
const compactor = bucketStorage.createMongoCompactor({}) as any;
|
|
2002
|
+
const failure = new Error('worker failed');
|
|
2003
|
+
const failing = Promise.withResolvers<void>();
|
|
2004
|
+
const draining = Promise.withResolvers<void>();
|
|
2005
|
+
const started: number[] = [];
|
|
2006
|
+
let settled = false;
|
|
2007
|
+
// The factory has two slots. With three workers, one also waits for a slot
|
|
2008
|
+
// when its sibling fails; it must release that slot without starting work.
|
|
2009
|
+
const run = compactor
|
|
2010
|
+
.runChunkCompactionWorkers(
|
|
2011
|
+
[0, 1, 2, 3, 4],
|
|
2012
|
+
Array.from({ length: workerCount }, () => compactor.objectStorageUsage),
|
|
2013
|
+
async (bucket: number) => {
|
|
2014
|
+
started.push(bucket);
|
|
2015
|
+
if (bucket === 0) await failing.promise;
|
|
2016
|
+
if (bucket === 1) await draining.promise;
|
|
2017
|
+
}
|
|
2018
|
+
)
|
|
2019
|
+
.then(
|
|
2020
|
+
() => undefined,
|
|
2021
|
+
(error: unknown) => error
|
|
2022
|
+
)
|
|
2023
|
+
.finally(() => {
|
|
2024
|
+
settled = true;
|
|
2025
|
+
});
|
|
2026
|
+
try {
|
|
2027
|
+
await vi.waitFor(() => expect(started).toEqual([0, 1]));
|
|
2028
|
+
failing.reject(failure);
|
|
2029
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
2030
|
+
expect(started).toEqual([0, 1]);
|
|
2031
|
+
expect(settled).toBe(false);
|
|
2032
|
+
} finally {
|
|
2033
|
+
failing.resolve();
|
|
2034
|
+
draining.resolve();
|
|
2035
|
+
await run;
|
|
2036
|
+
}
|
|
2037
|
+
expect(await run).toBe(failure);
|
|
2038
|
+
expect(started).toEqual([0, 1]);
|
|
2039
|
+
expect(bucketStorage.factory.chunkCompactionSlots.getValue()).toBe(2);
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
test('unforced chunk workers drain before full compaction and reclassify aged buckets on the next scan', async () => {
|
|
2043
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2044
|
+
const buckets = ['chunk1[]', 'chunk2[]', 'promoted[]', 'full1[]', 'full2[]'];
|
|
2045
|
+
for (const bucket of buckets) {
|
|
2046
|
+
const documents = Array.from({ length: 8 }, (_, index) =>
|
|
2047
|
+
serializeBucketData(bucket, [makeOp(index + 1, String(index), 'data', { ...ctx, bucket }, sourceTableId)])
|
|
2048
|
+
);
|
|
2049
|
+
await collection.insertMany(documents);
|
|
2050
|
+
await bucketStateCollection.insertOne({
|
|
2051
|
+
_id: { d: ctx.definitionId, b: bucket },
|
|
2052
|
+
last_op: 8n,
|
|
2053
|
+
next_compact_check: new Date(0),
|
|
2054
|
+
first_uncompacted_write: bucket.startsWith('full') ? new Date(0) : new Date(),
|
|
2055
|
+
bucket_stats: { count: 8, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const originalClaim = CompactionLease.claim.bind(CompactionLease);
|
|
2060
|
+
const jobStartedAt = new Date();
|
|
2061
|
+
let serverTime = jobStartedAt;
|
|
2062
|
+
let promoted = false;
|
|
2063
|
+
const claim = vi.spyOn(CompactionLease, 'claim').mockImplementation(async (...args) => {
|
|
2064
|
+
if (!promoted && (args[1]._id as { b: string })?.b === 'promoted[]') {
|
|
2065
|
+
promoted = true;
|
|
2066
|
+
// Advance the server clock past the full-compaction interval without
|
|
2067
|
+
// changing bucket state. A fixed scan timestamp would retry forever.
|
|
2068
|
+
serverTime = new Date(jobStartedAt.getTime() + 3 * 60 * 60 * 1000);
|
|
2069
|
+
}
|
|
2070
|
+
const lease = await originalClaim(...args);
|
|
2071
|
+
if (lease != null) {
|
|
2072
|
+
Object.defineProperty(lease, 'startedAt', { value: serverTime });
|
|
2073
|
+
}
|
|
2074
|
+
return lease;
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 8n });
|
|
2078
|
+
const internal = compactor as any;
|
|
2079
|
+
let timeReads = 0;
|
|
2080
|
+
const clock = vi.spyOn(internal, 'readCompactionTime').mockImplementation(async () => {
|
|
2081
|
+
if (++timeReads > 5) {
|
|
2082
|
+
throw new Error('Compaction did not advance after the bucket aged');
|
|
2083
|
+
}
|
|
2084
|
+
return serverTime;
|
|
2085
|
+
});
|
|
2086
|
+
const scan = vi.spyOn(internal, 'findScheduledBucketBatch');
|
|
2087
|
+
const originalChunks = internal.compactSingleBucketChunks.bind(compactor);
|
|
2088
|
+
const originalFull = internal.compactSingleBucketFully.bind(compactor);
|
|
2089
|
+
const gate = Promise.withResolvers<void>();
|
|
2090
|
+
let activeChunks = 0;
|
|
2091
|
+
let activeFull = 0;
|
|
2092
|
+
let maxActiveFull = 0;
|
|
2093
|
+
const fullBuckets: string[] = [];
|
|
2094
|
+
const chunks = vi.spyOn(internal, 'compactSingleBucketChunks').mockImplementation(async (...args) => {
|
|
2095
|
+
activeChunks++;
|
|
2096
|
+
try {
|
|
2097
|
+
await gate.promise;
|
|
2098
|
+
return await originalChunks(...args);
|
|
2099
|
+
} finally {
|
|
2100
|
+
activeChunks--;
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
const full = vi.spyOn(internal, 'compactSingleBucketFully').mockImplementation(async (...args) => {
|
|
2104
|
+
expect(activeChunks).toBe(0);
|
|
2105
|
+
activeFull++;
|
|
2106
|
+
maxActiveFull = Math.max(maxActiveFull, activeFull);
|
|
2107
|
+
fullBuckets.push((args[0] as any).state._id.b);
|
|
2108
|
+
try {
|
|
2109
|
+
return await originalFull(...args);
|
|
2110
|
+
} finally {
|
|
2111
|
+
activeFull--;
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
const run = compactor.compact();
|
|
2115
|
+
try {
|
|
2116
|
+
await vi.waitFor(() => expect(activeChunks).toBe(2));
|
|
2117
|
+
expect(full).not.toHaveBeenCalled();
|
|
2118
|
+
} finally {
|
|
2119
|
+
gate.resolve();
|
|
2120
|
+
try {
|
|
2121
|
+
await run;
|
|
2122
|
+
expect(timeReads).toBe(3); // Job start, first batch, and promoted bucket's batch.
|
|
2123
|
+
expect(scan).toHaveBeenCalledTimes(3); // Includes the final empty scan.
|
|
2124
|
+
for (const [cutoff] of scan.mock.calls) {
|
|
2125
|
+
expect(cutoff).toEqual(jobStartedAt);
|
|
2126
|
+
}
|
|
2127
|
+
} finally {
|
|
2128
|
+
clock.mockRestore();
|
|
2129
|
+
scan.mockRestore();
|
|
2130
|
+
claim.mockRestore();
|
|
2131
|
+
chunks.mockRestore();
|
|
2132
|
+
full.mockRestore();
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
expect(await run).toBe(5);
|
|
2136
|
+
expect(promoted).toBe(true);
|
|
2137
|
+
expect(maxActiveFull).toBe(1);
|
|
2138
|
+
expect(fullBuckets.sort()).toEqual(['full1[]', 'full2[]', 'promoted[]']);
|
|
2139
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
|
|
2140
|
+
expect(await collection.countDocuments()).toBe(5);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
|
|
2144
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2145
|
+
const operations = [
|
|
2146
|
+
makeOp(1, 'A', 'a', ctx, sourceTableId),
|
|
2147
|
+
makeOp(2, 'B', 'b', ctx, sourceTableId),
|
|
2148
|
+
makeOp(3, 'C', 'c', ctx, sourceTableId),
|
|
2149
|
+
makeOp(4, 'D', 'd', ctx, sourceTableId)
|
|
2150
|
+
];
|
|
2151
|
+
const originalDocuments = [
|
|
2152
|
+
serializeBucketData(BUCKET, operations.slice(0, 2)),
|
|
2153
|
+
serializeBucketData(BUCKET, operations.slice(2))
|
|
2154
|
+
];
|
|
2155
|
+
const committedMerge = serializeBucketData(BUCKET, operations);
|
|
2156
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
2157
|
+
await insertDocs(collection, [committedMerge, untouchedTail]);
|
|
2158
|
+
await bucketStateCollection.insertOne({
|
|
2159
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2160
|
+
last_op: 5n,
|
|
2161
|
+
next_compact_check: new Date(0),
|
|
2162
|
+
first_uncompacted_write: new Date(0),
|
|
2163
|
+
bucket_stats: {
|
|
2164
|
+
count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
|
|
2165
|
+
bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
|
|
2166
|
+
chunks: originalDocuments.length + 1
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
|
|
2171
|
+
|
|
2172
|
+
expect(result).toEqual({ buckets: 1 });
|
|
2173
|
+
const expectedStats = {
|
|
2174
|
+
count: committedMerge.count + untouchedTail.count,
|
|
2175
|
+
bytes: BigInt(committedMerge.size + untouchedTail.size),
|
|
2176
|
+
chunks: 2
|
|
2177
|
+
};
|
|
2178
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2179
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
2180
|
+
expect(state?.compacted_state).toMatchObject({
|
|
2181
|
+
op_id: 4n,
|
|
2182
|
+
checksum: committedMerge.checksum,
|
|
2183
|
+
count: committedMerge.count,
|
|
2184
|
+
bytes: BigInt(committedMerge.size),
|
|
2185
|
+
chunks: 1
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
test('later full compaction claims fresh state after a committed replacement', async () => {
|
|
2190
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2191
|
+
const documents = [
|
|
2192
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
2193
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
2194
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
2195
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
2196
|
+
];
|
|
2197
|
+
await insertDocs(collection, documents);
|
|
2198
|
+
await bucketStateCollection.insertOne({
|
|
2199
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2200
|
+
last_op: 4n,
|
|
2201
|
+
next_compact_check: new Date(0),
|
|
2202
|
+
first_uncompacted_write: new Date(0),
|
|
2203
|
+
bucket_stats: {
|
|
2204
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
2205
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
2206
|
+
chunks: documents.length
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
2211
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
|
|
2212
|
+
const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
|
|
2213
|
+
let injectedFailure = false;
|
|
2214
|
+
vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
|
|
2215
|
+
const result = await originalFlush(...args);
|
|
2216
|
+
if (!injectedFailure) {
|
|
2217
|
+
injectedFailure = true;
|
|
2218
|
+
await insertDocs(collection, [concurrentDocument]);
|
|
2219
|
+
await bucketStateCollection.updateOne(
|
|
2220
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
2221
|
+
{
|
|
2222
|
+
$set: { last_op: 5n },
|
|
2223
|
+
$inc: {
|
|
2224
|
+
'bucket_stats.count': concurrentDocument.count,
|
|
2225
|
+
'bucket_stats.bytes': BigInt(concurrentDocument.size),
|
|
2226
|
+
'bucket_stats.chunks': 1
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
);
|
|
2230
|
+
throw new ObjectStorageError('failure after committed full-compaction replacement', {
|
|
2231
|
+
cause: new Error('socket reset'),
|
|
2232
|
+
retryable: true
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
return result;
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
|
|
2239
|
+
|
|
2240
|
+
expect(injectedFailure).toBe(true);
|
|
2241
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2242
|
+
expect(interruptedState?.last_op).toBe(5n);
|
|
2243
|
+
expect(interruptedState?.compacted_state).toBeUndefined();
|
|
2244
|
+
expect(interruptedState?.compact_lease).toBeUndefined();
|
|
2245
|
+
|
|
2246
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
|
|
2247
|
+
1
|
|
2248
|
+
);
|
|
2249
|
+
|
|
2250
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2251
|
+
expect(currentDocuments).toHaveLength(1);
|
|
2252
|
+
const expectedStats = {
|
|
2253
|
+
count: currentDocuments.reduce((total, document) => total + document.count, 0),
|
|
2254
|
+
bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
|
|
2255
|
+
chunks: currentDocuments.length
|
|
2256
|
+
};
|
|
2257
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2258
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
2259
|
+
expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
|
|
2260
|
+
expect(state?.last_full_compact?.op_id).toBe(5n);
|
|
2261
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
2262
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
test('chunk compaction treats a missing cached op as a resume hint', async () => {
|
|
2266
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2267
|
+
const documents = [
|
|
2268
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
2269
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
2270
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
2271
|
+
];
|
|
2272
|
+
await insertDocs(collection, documents);
|
|
2273
|
+
await bucketStateCollection.insertOne({
|
|
2274
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2275
|
+
last_op: 4n,
|
|
2276
|
+
next_compact_check: new Date(0),
|
|
2277
|
+
first_uncompacted_write: new Date(0),
|
|
2278
|
+
compacted_state: {
|
|
2279
|
+
op_id: 2n,
|
|
2280
|
+
checksum: 21n,
|
|
2281
|
+
count: 2,
|
|
2282
|
+
bytes: 100n,
|
|
2283
|
+
chunks: 2,
|
|
2284
|
+
at: new Date(0)
|
|
2285
|
+
},
|
|
2286
|
+
bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
await expect(
|
|
2290
|
+
bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true }).compact()
|
|
2291
|
+
).resolves.toBe(1);
|
|
2292
|
+
|
|
2293
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2294
|
+
expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
|
|
2295
|
+
const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
|
|
2296
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2297
|
+
expect(state?.compacted_state).toMatchObject({
|
|
2298
|
+
op_id: 4n,
|
|
2299
|
+
checksum: 56n,
|
|
2300
|
+
count: 3,
|
|
2301
|
+
bytes,
|
|
2302
|
+
chunks: currentDocuments.length
|
|
2303
|
+
});
|
|
2304
|
+
expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
test('later chunk compaction rebuilds state after a committed partial merge', async () => {
|
|
2308
|
+
const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
|
|
2309
|
+
const failedCompactor = bucketStorage.createMongoCompactor({
|
|
2310
|
+
maxOpId: 4n,
|
|
2311
|
+
compactChunksOnly: true,
|
|
2312
|
+
forceChunkCompaction: true
|
|
2313
|
+
});
|
|
2314
|
+
const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
|
|
2315
|
+
const flushSpy = vi
|
|
2316
|
+
.spyOn(failedCompactor as any, 'flushCompactionGroup')
|
|
2317
|
+
.mockImplementation(async (...args: any[]) => {
|
|
2318
|
+
const result = await originalFlush(...args);
|
|
2319
|
+
throw new Error('failure after committed chunk merge');
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
await expect(failedCompactor.compact()).resolves.toBe(0);
|
|
2323
|
+
flushSpy.mockRestore();
|
|
2324
|
+
|
|
2325
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2326
|
+
expect(interruptedState?.compacted_state?.op_id).toBe(2n);
|
|
2327
|
+
expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
|
|
2328
|
+
await bucketStateCollection.updateOne(
|
|
2329
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
2330
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
2331
|
+
);
|
|
2332
|
+
|
|
2333
|
+
const result = await bucketStorage
|
|
2334
|
+
.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true })
|
|
2335
|
+
.compact();
|
|
2336
|
+
|
|
2337
|
+
expect(result).toBe(1);
|
|
2338
|
+
await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
|
|
2339
|
+
});
|
|
2340
|
+
|
|
1504
2341
|
test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
|
|
1505
2342
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1506
2343
|
|
|
@@ -1616,6 +2453,13 @@ bucket_definitions:
|
|
|
1616
2453
|
expect(op600).toBeDefined();
|
|
1617
2454
|
expect(op600!.op).toBe('PUT');
|
|
1618
2455
|
expect(op600!.row_id).toBe('F');
|
|
2456
|
+
|
|
2457
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2458
|
+
expect(state?.bucket_stats).toEqual({
|
|
2459
|
+
count: docsAfter.reduce((total, document) => total + document.count, 0),
|
|
2460
|
+
bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
|
|
2461
|
+
chunks: docsAfter.length
|
|
2462
|
+
});
|
|
1619
2463
|
});
|
|
1620
2464
|
|
|
1621
2465
|
test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
|