@powersync/service-module-mongodb-storage 0.20.0 → 0.22.0
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 +62 -0
- package/dist/storage/MongoBucketStorage.d.ts +36 -3
- package/dist/storage/MongoBucketStorage.js +372 -207
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
- package/dist/storage/implementation/MongoBucketBatch.js +44 -17
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
- package/dist/storage/implementation/MongoCompactor.js +27 -238
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- 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 +29 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +7 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
- package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
- 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 +49 -6
- 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 +27 -1
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +31 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
- 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 +65 -16
- 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 +10 -2
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
- package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -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 +91 -31
- 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 +76 -42
- 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 +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 +84 -4
- 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 +6 -6
- package/dist/storage/implementation/v3/source-table-utils.js +30 -37
- 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.js +1 -0
- 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 +507 -269
- package/src/storage/implementation/MongoBucketBatch.ts +67 -24
- package/src/storage/implementation/MongoCompactor.ts +35 -346
- package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
- package/src/storage/implementation/MongoStorageProvider.ts +15 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
- package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
- 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 +66 -10
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
- package/src/storage/implementation/db.ts +35 -2
- package/src/storage/implementation/models.ts +34 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
- 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 +87 -27
- 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 +138 -34
- package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -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 +111 -41
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
- 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 +320 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +91 -3
- 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 +39 -41
- package/src/storage/storage-index.ts +2 -0
- package/src/types/types.ts +42 -7
- package/src/utils/test-utils.ts +1 -0
- 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 +605 -16
- package/test/src/chunk_compaction_config.test.ts +38 -0
- package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +14 -6
- package/test/src/object_storage_usage.test.ts +267 -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 +273 -22
- package/test/src/storage_compacting.test.ts +968 -246
- package/test/src/storage_s3_checksums.test.ts +3 -4
- package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
- package/test/src/storage_s3_reading.test.ts +290 -2
- package/test/src/storage_s3_writing.test.ts +1 -0
- package/test/src/storage_sync.test.ts +336 -41
- 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, 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,15 +93,18 @@ 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
109
|
const bucketStorage = factory.getInstance(syncRules);
|
|
72
110
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
@@ -75,20 +113,17 @@ bucket_definitions:
|
|
|
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,18 +148,21 @@ 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
167
|
const bucketStorage = factory.getInstance(syncRules);
|
|
130
168
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
@@ -132,21 +170,23 @@ bucket_definitions:
|
|
|
132
170
|
await populate(bucketStorage, 2);
|
|
133
171
|
const { checkpoint } = await bucketStorage.getCheckpoint();
|
|
134
172
|
|
|
135
|
-
//
|
|
136
|
-
|
|
173
|
+
// V3's initial lite pass processes every bucket with no prior compact state.
|
|
174
|
+
// Earlier storage versions use the default minimum-change threshold.
|
|
175
|
+
const result0 = await bucketStorage.compactInitialReplication({
|
|
137
176
|
maxOpId: checkpoint
|
|
138
177
|
});
|
|
139
|
-
expect(result0.buckets).toEqual(0);
|
|
178
|
+
expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
|
|
140
179
|
|
|
141
|
-
//
|
|
142
|
-
|
|
180
|
+
// For V1/V2, lower the threshold to populate the checksum cache. V3 has
|
|
181
|
+
// already updated its compacted state, so another initial pass is a no-op.
|
|
182
|
+
const result1 = await bucketStorage.compactInitialReplication({
|
|
143
183
|
maxOpId: checkpoint,
|
|
144
184
|
minBucketChanges: 1
|
|
145
185
|
});
|
|
146
|
-
expect(result1.buckets).toEqual(2);
|
|
186
|
+
expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
|
|
147
187
|
|
|
148
|
-
//
|
|
149
|
-
const result2 = await bucketStorage.
|
|
188
|
+
// Repeating it stays a no-op.
|
|
189
|
+
const result2 = await bucketStorage.compactInitialReplication({
|
|
150
190
|
maxOpId: checkpoint,
|
|
151
191
|
minBucketChanges: 1
|
|
152
192
|
});
|
|
@@ -168,88 +208,82 @@ bucket_definitions:
|
|
|
168
208
|
});
|
|
169
209
|
});
|
|
170
210
|
|
|
171
|
-
test('
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
211
|
+
test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
|
|
212
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
213
|
+
const result = await (bucketStorage as MongoSyncBucketStorage)
|
|
214
|
+
.createMongoCompactor({
|
|
215
|
+
maxOpId: checkpoint,
|
|
216
|
+
compactChunksOnly: true,
|
|
217
|
+
minCompactChunkIntervalMs: 1
|
|
218
|
+
})
|
|
219
|
+
.compact();
|
|
220
|
+
|
|
221
|
+
expect(result).toBe(2);
|
|
222
|
+
});
|
|
182
223
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
|
|
188
|
-
bucketStorage.replicationStreamId
|
|
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
|
-
}
|
|
224
|
+
test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
|
|
225
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
226
|
+
const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
|
|
227
|
+
expect(firstResult.buckets).toBe(2);
|
|
226
228
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
|
|
230
|
+
bucketStorage.replicationStreamId
|
|
231
|
+
);
|
|
232
|
+
await bucketStateCollection.updateMany(
|
|
233
|
+
{},
|
|
234
|
+
{
|
|
235
|
+
$set: {
|
|
236
|
+
first_uncompacted_write: new Date(0),
|
|
237
|
+
next_compact_check: new Date(0)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
);
|
|
229
241
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
|
|
243
|
+
.aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
244
|
+
.toArray();
|
|
245
|
+
const result = await bucketStorage.compactInitialReplication({
|
|
246
|
+
maxOpId: checkpoint,
|
|
247
|
+
signal: AbortSignal.timeout(2_000)
|
|
233
248
|
});
|
|
234
|
-
const firstBatch = await dirtyBuckets.next();
|
|
235
249
|
|
|
236
|
-
expect(
|
|
237
|
-
|
|
238
|
-
expect(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
250
|
+
expect(result.buckets).toBe(0);
|
|
251
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
252
|
+
expect(states).toHaveLength(2);
|
|
253
|
+
for (const state of states) {
|
|
254
|
+
expect(state.next_compact_check!.getTime()).toBeGreaterThan(
|
|
255
|
+
now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('v3 replication writes initialize scheduled compaction state', async () => {
|
|
261
|
+
const { bucketStorage } = await setup();
|
|
262
|
+
const storageDb = bucketStorage.db;
|
|
242
263
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
264
|
+
if (!storageDb.storageConfig.incrementalReprocessing) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
|
|
268
|
+
bucketStorage.replicationStreamId
|
|
269
|
+
);
|
|
270
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
271
|
+
expect(states).toHaveLength(2);
|
|
272
|
+
for (const state of states) {
|
|
273
|
+
expect(state.last_op).toBeGreaterThan(0n);
|
|
274
|
+
expect(state.bucket_stats.count).toBe(1);
|
|
275
|
+
expect(state.bucket_stats.chunks).toBe(1);
|
|
276
|
+
expect(state.first_uncompacted_write).toBeInstanceOf(Date);
|
|
277
|
+
expect(state.next_compact_check).toBeInstanceOf(Date);
|
|
278
|
+
expect(state.compacted_state).toBeUndefined();
|
|
279
|
+
expect(state).not.toHaveProperty('estimate_since_compact');
|
|
280
|
+
|
|
281
|
+
const docs = await (storageDb as VersionedPowerSyncMongoV3)
|
|
282
|
+
.bucketData(bucketStorage.replicationStreamId, state._id.d)
|
|
283
|
+
.find({ '_id.b': state._id.b })
|
|
284
|
+
.toArray();
|
|
285
|
+
expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
|
|
286
|
+
}
|
|
253
287
|
});
|
|
254
288
|
});
|
|
255
289
|
});
|
|
@@ -284,14 +318,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
|
|
|
284
318
|
*/
|
|
285
319
|
describe('V3 invariant verification', () => {
|
|
286
320
|
const BUCKET = 'global[]';
|
|
287
|
-
const TABLE = 'items';
|
|
288
321
|
|
|
289
322
|
function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
|
|
323
|
+
const sourceTable = new bson.ObjectId();
|
|
324
|
+
const sourceKey = 'key';
|
|
290
325
|
return {
|
|
291
326
|
bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
|
|
292
327
|
op: 'PUT',
|
|
293
|
-
source_table:
|
|
294
|
-
source_key:
|
|
328
|
+
source_table: sourceTable,
|
|
329
|
+
source_key: sourceKey,
|
|
330
|
+
subkey: replicaIdToSubkey(sourceTable, sourceKey),
|
|
295
331
|
table: 'test',
|
|
296
332
|
row_id: 'row1',
|
|
297
333
|
checksum: 1n,
|
|
@@ -329,31 +365,6 @@ bucket_definitions:
|
|
|
329
365
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
|
|
330
366
|
}
|
|
331
367
|
|
|
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
368
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
358
369
|
await collection.insertMany(docs);
|
|
359
370
|
}
|
|
@@ -362,7 +373,9 @@ bucket_definitions:
|
|
|
362
373
|
await bucketStateCollection.insertOne({
|
|
363
374
|
_id: { d: definitionId, b: BUCKET },
|
|
364
375
|
last_op: lastOp,
|
|
365
|
-
|
|
376
|
+
next_compact_check: new Date(0),
|
|
377
|
+
first_uncompacted_write: new Date(0),
|
|
378
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
366
379
|
});
|
|
367
380
|
}
|
|
368
381
|
|
|
@@ -371,12 +384,136 @@ bucket_definitions:
|
|
|
371
384
|
clearBatchLimit: 200,
|
|
372
385
|
moveBatchLimit: 10,
|
|
373
386
|
moveBatchQueryLimit: 10,
|
|
374
|
-
minBucketChanges: 1,
|
|
375
|
-
minChangeRatio: 0,
|
|
376
387
|
maxOpId
|
|
377
388
|
});
|
|
378
389
|
}
|
|
379
390
|
|
|
391
|
+
test('a successful lease renewal clears a transient renewal error', async () => {
|
|
392
|
+
const { bucketStateCollection, ctx } = await setupV3Storage();
|
|
393
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
|
|
394
|
+
const lease = await CompactionLease.claim(
|
|
395
|
+
bucketStateCollection,
|
|
396
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
397
|
+
undefined,
|
|
398
|
+
10 * 60 * 1000
|
|
399
|
+
);
|
|
400
|
+
expect(lease).not.toBeNull();
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const transientError = new Error('temporary MongoDB error');
|
|
404
|
+
const renew = (lease as any).renew.bind(lease);
|
|
405
|
+
const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
|
|
406
|
+
await renew().catch((error: unknown) => {
|
|
407
|
+
(lease as any).renewalError = error;
|
|
408
|
+
});
|
|
409
|
+
await expect(lease!.throwIfLost()).rejects.toBe(transientError);
|
|
410
|
+
|
|
411
|
+
updateOne.mockRestore();
|
|
412
|
+
await renew();
|
|
413
|
+
await expect(lease!.throwIfLost()).resolves.toBeUndefined();
|
|
414
|
+
} finally {
|
|
415
|
+
await lease?.[Symbol.asyncDispose]();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('a failed scheduled bucket does not block other scheduled buckets', async () => {
|
|
420
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
421
|
+
const badBucket = 'bad[]';
|
|
422
|
+
const goodBucket = 'good[]';
|
|
423
|
+
const goodDocument = serializeBucketData(goodBucket, [
|
|
424
|
+
makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
|
|
425
|
+
]);
|
|
426
|
+
await insertDocs(collection, [goodDocument]);
|
|
427
|
+
await bucketStateCollection.insertMany([
|
|
428
|
+
{
|
|
429
|
+
_id: { d: ctx.definitionId, b: badBucket },
|
|
430
|
+
last_op: 2n,
|
|
431
|
+
next_compact_check: new Date(0),
|
|
432
|
+
// A malformed scheduled bucket with an expired lease must be
|
|
433
|
+
// rescheduled without preventing valid buckets from compacting.
|
|
434
|
+
first_uncompacted_write: new Date(0),
|
|
435
|
+
bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
|
|
436
|
+
compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
_id: { d: ctx.definitionId, b: goodBucket },
|
|
440
|
+
last_op: 2n,
|
|
441
|
+
next_compact_check: new Date(0),
|
|
442
|
+
first_uncompacted_write: new Date(0),
|
|
443
|
+
bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
|
|
444
|
+
}
|
|
445
|
+
]);
|
|
446
|
+
await bucketStateCollection.updateOne(
|
|
447
|
+
{ _id: { d: ctx.definitionId, b: badBucket } },
|
|
448
|
+
{ $unset: { first_uncompacted_write: '' } }
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await (bucketStorage as MongoSyncBucketStorage)
|
|
452
|
+
.createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
|
|
453
|
+
.compact();
|
|
454
|
+
|
|
455
|
+
const [badState, goodState] = await Promise.all([
|
|
456
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
|
|
457
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
|
|
458
|
+
]);
|
|
459
|
+
expect(badState?.next_compact_check).toBeInstanceOf(Date);
|
|
460
|
+
expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
461
|
+
expect(goodState?.compacted_state?.op_id).toBe(2n);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
|
|
465
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
466
|
+
const buckets = Array.from({ length: 8 }, (_, index) => `bucket${index}[]`);
|
|
467
|
+
const documents = buckets.map((bucket) =>
|
|
468
|
+
serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
|
|
469
|
+
);
|
|
470
|
+
await insertDocs(collection, documents);
|
|
471
|
+
await bucketStateCollection.insertMany(
|
|
472
|
+
documents.map((document, index) => ({
|
|
473
|
+
_id: { d: ctx.definitionId, b: buckets[index] },
|
|
474
|
+
last_op: 2n,
|
|
475
|
+
next_compact_check: new Date(0),
|
|
476
|
+
first_uncompacted_write: new Date(0),
|
|
477
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
478
|
+
}))
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const abortController = new AbortController();
|
|
482
|
+
const testLogger = defaultLogger.child({});
|
|
483
|
+
vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
|
|
484
|
+
if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
|
|
485
|
+
abortController.abort();
|
|
486
|
+
}
|
|
487
|
+
return testLogger;
|
|
488
|
+
});
|
|
489
|
+
const errorLog = vi.spyOn(testLogger, 'error');
|
|
490
|
+
|
|
491
|
+
await expect(
|
|
492
|
+
bucketStorage
|
|
493
|
+
.createMongoCompactor({
|
|
494
|
+
maxOpId: 2n,
|
|
495
|
+
compactChunksOnly: true,
|
|
496
|
+
signal: abortController.signal,
|
|
497
|
+
logger: testLogger
|
|
498
|
+
})
|
|
499
|
+
.compact()
|
|
500
|
+
).rejects.toThrow();
|
|
501
|
+
|
|
502
|
+
const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
|
|
503
|
+
const completed = states.filter((state) => state.compacted_state != null);
|
|
504
|
+
const remaining = states.filter((state) => state.compacted_state == null);
|
|
505
|
+
// Already-started buckets may finish after abort, but the queued remainder
|
|
506
|
+
// must not be claimed or rescheduled.
|
|
507
|
+
expect(completed.length).toBeGreaterThanOrEqual(1);
|
|
508
|
+
expect(completed.length).toBeLessThanOrEqual(2);
|
|
509
|
+
expect(remaining.length).toBeGreaterThanOrEqual(6);
|
|
510
|
+
for (const state of remaining) {
|
|
511
|
+
expect(state.next_compact_check).toEqual(new Date(0));
|
|
512
|
+
expect(state.compact_lease).toBeUndefined();
|
|
513
|
+
}
|
|
514
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
515
|
+
});
|
|
516
|
+
|
|
380
517
|
test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
|
|
381
518
|
const ops = [
|
|
382
519
|
makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
|
|
@@ -746,7 +883,6 @@ bucket_definitions:
|
|
|
746
883
|
|
|
747
884
|
describe('V3 checksum pipeline straddling', () => {
|
|
748
885
|
const BUCKET = 'global[]';
|
|
749
|
-
const TABLE = 'items';
|
|
750
886
|
|
|
751
887
|
async function setup() {
|
|
752
888
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -776,30 +912,6 @@ bucket_definitions:
|
|
|
776
912
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
|
|
777
913
|
}
|
|
778
914
|
|
|
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
915
|
function checksumRequest(): storage.BucketChecksumRequest {
|
|
804
916
|
return {
|
|
805
917
|
bucket: BUCKET,
|
|
@@ -835,7 +947,9 @@ bucket_definitions:
|
|
|
835
947
|
await bucketStateCollection.insertOne({
|
|
836
948
|
_id: { d: definitionId, b: BUCKET },
|
|
837
949
|
last_op: 30n,
|
|
838
|
-
|
|
950
|
+
next_compact_check: undefined,
|
|
951
|
+
first_uncompacted_write: undefined,
|
|
952
|
+
bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
|
|
839
953
|
});
|
|
840
954
|
|
|
841
955
|
const request = checksumRequest();
|
|
@@ -874,7 +988,9 @@ bucket_definitions:
|
|
|
874
988
|
await bucketStateCollection.insertOne({
|
|
875
989
|
_id: { d: definitionId, b: BUCKET },
|
|
876
990
|
last_op: 0n,
|
|
877
|
-
|
|
991
|
+
next_compact_check: undefined,
|
|
992
|
+
first_uncompacted_write: undefined,
|
|
993
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
878
994
|
});
|
|
879
995
|
|
|
880
996
|
const request = checksumRequest();
|
|
@@ -894,7 +1010,9 @@ bucket_definitions:
|
|
|
894
1010
|
await bucketStateCollection.insertOne({
|
|
895
1011
|
_id: { d: definitionId, b: BUCKET },
|
|
896
1012
|
last_op: 0n,
|
|
897
|
-
|
|
1013
|
+
next_compact_check: undefined,
|
|
1014
|
+
first_uncompacted_write: undefined,
|
|
1015
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
898
1016
|
});
|
|
899
1017
|
|
|
900
1018
|
const request = checksumRequest();
|
|
@@ -913,7 +1031,9 @@ bucket_definitions:
|
|
|
913
1031
|
await bucketStateCollection.insertOne({
|
|
914
1032
|
_id: { d: definitionId, b: BUCKET },
|
|
915
1033
|
last_op: 10n,
|
|
916
|
-
|
|
1034
|
+
next_compact_check: undefined,
|
|
1035
|
+
first_uncompacted_write: undefined,
|
|
1036
|
+
bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
|
|
917
1037
|
});
|
|
918
1038
|
|
|
919
1039
|
const request = checksumRequest();
|
|
@@ -926,7 +1046,7 @@ bucket_definitions:
|
|
|
926
1046
|
await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
|
|
927
1047
|
await bucketStateCollection.updateOne(
|
|
928
1048
|
{ _id: { d: definitionId, b: BUCKET } },
|
|
929
|
-
{ $set: { last_op: 30n, '
|
|
1049
|
+
{ $set: { last_op: 30n, 'bucket_stats.count': 2 } }
|
|
930
1050
|
);
|
|
931
1051
|
|
|
932
1052
|
const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
@@ -940,7 +1060,6 @@ bucket_definitions:
|
|
|
940
1060
|
|
|
941
1061
|
describe('V3 compaction boundaries', () => {
|
|
942
1062
|
const BUCKET = 'global[]';
|
|
943
|
-
const TABLE = 'items';
|
|
944
1063
|
|
|
945
1064
|
async function setupV3() {
|
|
946
1065
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -971,31 +1090,6 @@ bucket_definitions:
|
|
|
971
1090
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
972
1091
|
}
|
|
973
1092
|
|
|
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
1093
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1000
1094
|
await collection.insertMany(docs);
|
|
1001
1095
|
}
|
|
@@ -1004,7 +1098,9 @@ bucket_definitions:
|
|
|
1004
1098
|
await bucketStateCollection.insertOne({
|
|
1005
1099
|
_id: { d: definitionId, b: BUCKET },
|
|
1006
1100
|
last_op: lastOp,
|
|
1007
|
-
|
|
1101
|
+
next_compact_check: new Date(0),
|
|
1102
|
+
first_uncompacted_write: new Date(0),
|
|
1103
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1008
1104
|
});
|
|
1009
1105
|
}
|
|
1010
1106
|
|
|
@@ -1030,6 +1126,156 @@ bucket_definitions:
|
|
|
1030
1126
|
return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1031
1127
|
}
|
|
1032
1128
|
|
|
1129
|
+
test('scheduled compaction claims only due buckets and clears completed full work', async () => {
|
|
1130
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1131
|
+
const docs = [
|
|
1132
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
|
|
1133
|
+
serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
|
|
1134
|
+
];
|
|
1135
|
+
await insertDocs(collection, docs);
|
|
1136
|
+
await bucketStateCollection.insertOne({
|
|
1137
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1138
|
+
last_op: 2n,
|
|
1139
|
+
next_compact_check: new Date(Date.now() + 60_000),
|
|
1140
|
+
first_uncompacted_write: new Date(Date.now() - 60_000),
|
|
1141
|
+
bucket_stats: {
|
|
1142
|
+
count: 2,
|
|
1143
|
+
bytes: BigInt(docs[0].size + docs[1].size),
|
|
1144
|
+
chunks: 2
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1149
|
+
expect(
|
|
1150
|
+
(await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
|
|
1151
|
+
).toBeUndefined();
|
|
1152
|
+
|
|
1153
|
+
await bucketStateCollection.updateOne(
|
|
1154
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1155
|
+
{ $set: { next_compact_check: new Date(Date.now() - 1) } }
|
|
1156
|
+
);
|
|
1157
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1158
|
+
|
|
1159
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1160
|
+
expect(state?.last_full_compact?.op_id).toBe(2n);
|
|
1161
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1162
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1163
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
|
|
1167
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1168
|
+
const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
|
|
1169
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
|
|
1170
|
+
await insertDocs(collection, [prefix, untouchedTail]);
|
|
1171
|
+
await bucketStateCollection.insertOne({
|
|
1172
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1173
|
+
last_op: 2n,
|
|
1174
|
+
next_compact_check: new Date(0),
|
|
1175
|
+
first_uncompacted_write: new Date(0),
|
|
1176
|
+
bucket_stats: {
|
|
1177
|
+
count: prefix.count + untouchedTail.count,
|
|
1178
|
+
bytes: BigInt(prefix.size + untouchedTail.size),
|
|
1179
|
+
chunks: 2
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1184
|
+
|
|
1185
|
+
const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1186
|
+
expect(partialState?.last_full_compact).toMatchObject({
|
|
1187
|
+
op_id: 1n,
|
|
1188
|
+
count: prefix.count,
|
|
1189
|
+
puts: 1
|
|
1190
|
+
});
|
|
1191
|
+
expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
|
|
1192
|
+
expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
|
|
1193
|
+
expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
|
|
1194
|
+
partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
const lastFullCompactAt = partialState!.last_full_compact!.at;
|
|
1198
|
+
const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
|
|
1199
|
+
await bucketStateCollection.updateOne(
|
|
1200
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1201
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
await bucketStorage.compact({ maxOpId: 2n });
|
|
1205
|
+
|
|
1206
|
+
const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1207
|
+
expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1208
|
+
expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
|
|
1209
|
+
expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
1210
|
+
|
|
1211
|
+
// Even after the scheduling window has elapsed, a run with the old cap
|
|
1212
|
+
// must not scan and record the same full-compaction prefix again.
|
|
1213
|
+
await bucketStateCollection.updateOne(
|
|
1214
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1215
|
+
{ $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1219
|
+
|
|
1220
|
+
const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1221
|
+
expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1222
|
+
expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
|
|
1223
|
+
expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
|
|
1227
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1228
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1229
|
+
await insertDocs(collection, [document]);
|
|
1230
|
+
await bucketStateCollection.insertOne({
|
|
1231
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1232
|
+
last_op: 1n,
|
|
1233
|
+
next_compact_check: undefined,
|
|
1234
|
+
first_uncompacted_write: undefined,
|
|
1235
|
+
compacted_state: {
|
|
1236
|
+
op_id: 1n,
|
|
1237
|
+
checksum: document.checksum,
|
|
1238
|
+
count: document.count,
|
|
1239
|
+
bytes: BigInt(document.size),
|
|
1240
|
+
chunks: 1,
|
|
1241
|
+
at: new Date()
|
|
1242
|
+
},
|
|
1243
|
+
last_full_compact: {
|
|
1244
|
+
op_id: 1n,
|
|
1245
|
+
count: document.count,
|
|
1246
|
+
puts: 1,
|
|
1247
|
+
at: new Date()
|
|
1248
|
+
},
|
|
1249
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
|
|
1253
|
+
|
|
1254
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1255
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1256
|
+
expect(state?.next_compact_check).toBeNull();
|
|
1257
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
test('concurrent scheduled compactors lease a bucket to one worker', async () => {
|
|
1261
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1262
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1263
|
+
await insertDocs(collection, [document]);
|
|
1264
|
+
await bucketStateCollection.insertOne({
|
|
1265
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1266
|
+
last_op: 1n,
|
|
1267
|
+
next_compact_check: new Date(Date.now() - 1),
|
|
1268
|
+
first_uncompacted_write: new Date(0),
|
|
1269
|
+
bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
|
|
1273
|
+
|
|
1274
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1275
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1276
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1033
1279
|
test('1. superseded ops become MOVE tombstones', async () => {
|
|
1034
1280
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1035
1281
|
// Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
|
|
@@ -1207,7 +1453,6 @@ bucket_definitions:
|
|
|
1207
1453
|
|
|
1208
1454
|
describe('V3 MOVE tombstone properties', () => {
|
|
1209
1455
|
const BUCKET = 'global[]';
|
|
1210
|
-
const TABLE = 'items';
|
|
1211
1456
|
|
|
1212
1457
|
async function setupV3() {
|
|
1213
1458
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -1238,31 +1483,6 @@ bucket_definitions:
|
|
|
1238
1483
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1239
1484
|
}
|
|
1240
1485
|
|
|
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
1486
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1267
1487
|
await collection.insertMany(docs);
|
|
1268
1488
|
}
|
|
@@ -1271,7 +1491,9 @@ bucket_definitions:
|
|
|
1271
1491
|
await bucketStateCollection.insertOne({
|
|
1272
1492
|
_id: { d: definitionId, b: BUCKET },
|
|
1273
1493
|
last_op: lastOp,
|
|
1274
|
-
|
|
1494
|
+
next_compact_check: new Date(0),
|
|
1495
|
+
first_uncompacted_write: new Date(0),
|
|
1496
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1275
1497
|
});
|
|
1276
1498
|
}
|
|
1277
1499
|
|
|
@@ -1420,10 +1642,9 @@ bucket_definitions:
|
|
|
1420
1642
|
*/
|
|
1421
1643
|
describe('Streaming compactor', () => {
|
|
1422
1644
|
const BUCKET = 'global[]';
|
|
1423
|
-
const TABLE = 'items';
|
|
1424
1645
|
|
|
1425
|
-
async function setupV3() {
|
|
1426
|
-
await using factory = await
|
|
1646
|
+
async function setupV3(factoryGenerator = INITIALIZED_MONGO_STORAGE_FACTORY) {
|
|
1647
|
+
await using factory = await factoryGenerator.factory();
|
|
1427
1648
|
const syncRules = await factory.updateSyncRules(
|
|
1428
1649
|
updateSyncRulesFromYaml(
|
|
1429
1650
|
`
|
|
@@ -1450,31 +1671,6 @@ bucket_definitions:
|
|
|
1450
1671
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1451
1672
|
}
|
|
1452
1673
|
|
|
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
1674
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1479
1675
|
await collection.insertMany(docs);
|
|
1480
1676
|
}
|
|
@@ -1483,7 +1679,9 @@ bucket_definitions:
|
|
|
1483
1679
|
await bucketStateCollection.insertOne({
|
|
1484
1680
|
_id: { d: definitionId, b: BUCKET },
|
|
1485
1681
|
last_op: lastOp,
|
|
1486
|
-
|
|
1682
|
+
next_compact_check: new Date(0),
|
|
1683
|
+
first_uncompacted_write: new Date(0),
|
|
1684
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1487
1685
|
});
|
|
1488
1686
|
}
|
|
1489
1687
|
|
|
@@ -1501,6 +1699,523 @@ bucket_definitions:
|
|
|
1501
1699
|
);
|
|
1502
1700
|
}
|
|
1503
1701
|
|
|
1702
|
+
async function setupCompactedTail() {
|
|
1703
|
+
const setup = await setupV3();
|
|
1704
|
+
const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
|
|
1705
|
+
const documents = [
|
|
1706
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1707
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1708
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1709
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1710
|
+
];
|
|
1711
|
+
await insertDocs(collection, documents);
|
|
1712
|
+
await bucketStateCollection.insertOne({
|
|
1713
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1714
|
+
last_op: 4n,
|
|
1715
|
+
next_compact_check: new Date(0),
|
|
1716
|
+
first_uncompacted_write: new Date(0),
|
|
1717
|
+
compacted_state: {
|
|
1718
|
+
op_id: 2n,
|
|
1719
|
+
checksum: documents[0].checksum + documents[1].checksum,
|
|
1720
|
+
count: documents[0].count + documents[1].count,
|
|
1721
|
+
bytes: BigInt(documents[0].size + documents[1].size),
|
|
1722
|
+
chunks: 2,
|
|
1723
|
+
at: new Date(0)
|
|
1724
|
+
},
|
|
1725
|
+
bucket_stats: {
|
|
1726
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1727
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1728
|
+
chunks: documents.length
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
return setup;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
|
|
1735
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1736
|
+
expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
|
|
1737
|
+
1n,
|
|
1738
|
+
2n,
|
|
1739
|
+
3n,
|
|
1740
|
+
4n
|
|
1741
|
+
]);
|
|
1742
|
+
|
|
1743
|
+
const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
|
|
1744
|
+
const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
|
|
1745
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1746
|
+
op_id: 4n,
|
|
1747
|
+
checksum: 70n,
|
|
1748
|
+
count: 4,
|
|
1749
|
+
bytes,
|
|
1750
|
+
chunks: documents.length
|
|
1751
|
+
});
|
|
1752
|
+
expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
|
|
1756
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1757
|
+
await insertDocs(collection, [
|
|
1758
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1759
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1760
|
+
]);
|
|
1761
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
|
|
1762
|
+
|
|
1763
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
1764
|
+
|
|
1765
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1766
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1767
|
+
expect(documents).toHaveLength(1);
|
|
1768
|
+
expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
|
|
1769
|
+
|
|
1770
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1771
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1772
|
+
op_id: 4n,
|
|
1773
|
+
count: 4,
|
|
1774
|
+
checksum: 70n
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
test.each([
|
|
1779
|
+
{ payloadSize: 100, workers: undefined, abort: false },
|
|
1780
|
+
{ payloadSize: 450_000, workers: undefined, abort: false },
|
|
1781
|
+
{ payloadSize: 450_000, workers: 4, abort: false },
|
|
1782
|
+
{ payloadSize: 100, workers: 1, abort: false },
|
|
1783
|
+
{ payloadSize: 450_000, workers: 4, abort: true }
|
|
1784
|
+
])('chunk compaction shares configured workers across runs: %o', async ({ payloadSize, workers, abort }) => {
|
|
1785
|
+
const concurrency = workers ?? 4;
|
|
1786
|
+
const objectStorage = new MemoryObjectStorage();
|
|
1787
|
+
const generator = mongoTestStorageFactoryGenerator({
|
|
1788
|
+
url: env.MONGO_TEST_URL,
|
|
1789
|
+
isCI: env.CI,
|
|
1790
|
+
objectStorage,
|
|
1791
|
+
chunkCompactionConcurrency: workers,
|
|
1792
|
+
inlineThresholdBytes: 0
|
|
1793
|
+
});
|
|
1794
|
+
const { bucketStorage, syncRules, collection, bucketStateCollection, ctx, sourceTableId, db } =
|
|
1795
|
+
await setupV3(generator);
|
|
1796
|
+
const bucketCount = 12;
|
|
1797
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
1798
|
+
const bucket = `global[${i}]`;
|
|
1799
|
+
const documents = [1, 2].map((id) =>
|
|
1800
|
+
serializeBucketData(bucket, [makeOp(id, String(id), 'x'.repeat(payloadSize), ctx, sourceTableId)])
|
|
1801
|
+
);
|
|
1802
|
+
await collection.insertMany(documents);
|
|
1803
|
+
await bucketStateCollection.insertOne({
|
|
1804
|
+
_id: { d: ctx.definitionId, b: bucket },
|
|
1805
|
+
last_op: 2n,
|
|
1806
|
+
next_compact_check: new Date(0),
|
|
1807
|
+
first_uncompacted_write: new Date(0),
|
|
1808
|
+
bucket_stats: {
|
|
1809
|
+
count: 2,
|
|
1810
|
+
bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)),
|
|
1811
|
+
chunks: 2
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const gate = Promise.withResolvers<void>();
|
|
1817
|
+
const originalPut = objectStorage.put.bind(objectStorage);
|
|
1818
|
+
let uploads = 0;
|
|
1819
|
+
const put = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
|
|
1820
|
+
uploads++;
|
|
1821
|
+
await gate.promise;
|
|
1822
|
+
await originalPut(...args);
|
|
1823
|
+
});
|
|
1824
|
+
const controller = new AbortController();
|
|
1825
|
+
let settled = false;
|
|
1826
|
+
const runs = Promise.allSettled(
|
|
1827
|
+
Array.from({ length: 2 }, () =>
|
|
1828
|
+
bucketStorage.factory
|
|
1829
|
+
.getInstance(syncRules)
|
|
1830
|
+
.compactInitialReplication({ maxOpId: 2n, signal: controller.signal })
|
|
1831
|
+
)
|
|
1832
|
+
).finally(() => {
|
|
1833
|
+
settled = true;
|
|
1834
|
+
});
|
|
1835
|
+
try {
|
|
1836
|
+
await vi.waitFor(() => expect(uploads).toBe(concurrency));
|
|
1837
|
+
// Let other ready workers reach admission; the held uploads must prevent
|
|
1838
|
+
// any additional payloads from reaching S3, across both compactor instances.
|
|
1839
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1840
|
+
expect(uploads).toBe(concurrency);
|
|
1841
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBeLessThanOrEqual(
|
|
1842
|
+
concurrency
|
|
1843
|
+
);
|
|
1844
|
+
expect(await collection.countDocuments()).toBe(bucketCount * 2);
|
|
1845
|
+
if (abort) {
|
|
1846
|
+
controller.abort();
|
|
1847
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1848
|
+
expect(settled).toBe(false);
|
|
1849
|
+
}
|
|
1850
|
+
} finally {
|
|
1851
|
+
gate.resolve();
|
|
1852
|
+
await runs;
|
|
1853
|
+
put.mockRestore();
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const results = await runs;
|
|
1857
|
+
if (abort) {
|
|
1858
|
+
expect(results.every((result) => result.status === 'rejected')).toBe(true);
|
|
1859
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
|
|
1860
|
+
// Cancellation must return all worker slots.
|
|
1861
|
+
expect(await bucketStorage.compactInitialReplication({ maxOpId: 2n })).toEqual({ buckets: bucketCount });
|
|
1862
|
+
} else {
|
|
1863
|
+
expect(results.every((result) => result.status === 'fulfilled')).toBe(true);
|
|
1864
|
+
expect(results.reduce((sum, result) => sum + (result.status === 'fulfilled' ? result.value.buckets : 0), 0)).toBe(
|
|
1865
|
+
bucketCount
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const documents = await collection.find().toArray();
|
|
1870
|
+
expect(documents).toHaveLength(bucketCount);
|
|
1871
|
+
expect(documents.every((doc) => doc.count === 2 && doc.checksum === 21n && doc.storage_ref != null)).toBe(true);
|
|
1872
|
+
expect(
|
|
1873
|
+
await bucketStateCollection.countDocuments({ 'compacted_state.op_id': 2n, compact_lease: { $exists: false } })
|
|
1874
|
+
).toBe(bucketCount);
|
|
1875
|
+
const usage = await db.objectStorageUsage.find({ '_id.g': ctx.replicationStreamId }).toArray();
|
|
1876
|
+
const activeBytes = usage.reduce((sum, doc) => sum + BigInt(doc.definitions[ctx.definitionId] ?? 0), 0n);
|
|
1877
|
+
expect(activeBytes).toBe(documents.reduce((sum, doc) => sum + BigInt(doc.storage_ref!.file_size), 0n));
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
test.each([2, 3])('failed chunk worker stops queued work and drains siblings (%s workers)', async (workerCount) => {
|
|
1881
|
+
const { bucketStorage } = await setupV3();
|
|
1882
|
+
const compactor = bucketStorage.createMongoCompactor({}) as any;
|
|
1883
|
+
const failure = new Error('worker failed');
|
|
1884
|
+
const failing = Promise.withResolvers<void>();
|
|
1885
|
+
const draining = Promise.withResolvers<void>();
|
|
1886
|
+
const started: number[] = [];
|
|
1887
|
+
let settled = false;
|
|
1888
|
+
// The factory has two slots. With three workers, one also waits for a slot
|
|
1889
|
+
// when its sibling fails; it must release that slot without starting work.
|
|
1890
|
+
const run = compactor
|
|
1891
|
+
.runChunkCompactionWorkers(
|
|
1892
|
+
[0, 1, 2, 3, 4],
|
|
1893
|
+
Array.from({ length: workerCount }, () => compactor.objectStorageUsage),
|
|
1894
|
+
async (bucket: number) => {
|
|
1895
|
+
started.push(bucket);
|
|
1896
|
+
if (bucket === 0) await failing.promise;
|
|
1897
|
+
if (bucket === 1) await draining.promise;
|
|
1898
|
+
}
|
|
1899
|
+
)
|
|
1900
|
+
.then(
|
|
1901
|
+
() => undefined,
|
|
1902
|
+
(error: unknown) => error
|
|
1903
|
+
)
|
|
1904
|
+
.finally(() => {
|
|
1905
|
+
settled = true;
|
|
1906
|
+
});
|
|
1907
|
+
try {
|
|
1908
|
+
await vi.waitFor(() => expect(started).toEqual([0, 1]));
|
|
1909
|
+
failing.reject(failure);
|
|
1910
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
1911
|
+
expect(started).toEqual([0, 1]);
|
|
1912
|
+
expect(settled).toBe(false);
|
|
1913
|
+
} finally {
|
|
1914
|
+
failing.resolve();
|
|
1915
|
+
draining.resolve();
|
|
1916
|
+
await run;
|
|
1917
|
+
}
|
|
1918
|
+
expect(await run).toBe(failure);
|
|
1919
|
+
expect(started).toEqual([0, 1]);
|
|
1920
|
+
expect(bucketStorage.factory.chunkCompactionSlots.getValue()).toBe(2);
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
test('unforced chunk workers drain before full compaction and reclassify aged buckets on the next scan', async () => {
|
|
1924
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1925
|
+
const buckets = ['chunk1[]', 'chunk2[]', 'promoted[]', 'full1[]', 'full2[]'];
|
|
1926
|
+
for (const bucket of buckets) {
|
|
1927
|
+
const documents = Array.from({ length: 8 }, (_, index) =>
|
|
1928
|
+
serializeBucketData(bucket, [makeOp(index + 1, String(index), 'data', { ...ctx, bucket }, sourceTableId)])
|
|
1929
|
+
);
|
|
1930
|
+
await collection.insertMany(documents);
|
|
1931
|
+
await bucketStateCollection.insertOne({
|
|
1932
|
+
_id: { d: ctx.definitionId, b: bucket },
|
|
1933
|
+
last_op: 8n,
|
|
1934
|
+
next_compact_check: new Date(0),
|
|
1935
|
+
first_uncompacted_write: bucket.startsWith('full') ? new Date(0) : new Date(),
|
|
1936
|
+
bucket_stats: { count: 8, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const originalClaim = CompactionLease.claim.bind(CompactionLease);
|
|
1941
|
+
const jobStartedAt = new Date();
|
|
1942
|
+
let serverTime = jobStartedAt;
|
|
1943
|
+
let promoted = false;
|
|
1944
|
+
const claim = vi.spyOn(CompactionLease, 'claim').mockImplementation(async (...args) => {
|
|
1945
|
+
if (!promoted && (args[1]._id as { b: string })?.b === 'promoted[]') {
|
|
1946
|
+
promoted = true;
|
|
1947
|
+
// Advance the server clock past the full-compaction interval without
|
|
1948
|
+
// changing bucket state. A fixed scan timestamp would retry forever.
|
|
1949
|
+
serverTime = new Date(jobStartedAt.getTime() + 3 * 60 * 60 * 1000);
|
|
1950
|
+
}
|
|
1951
|
+
const lease = await originalClaim(...args);
|
|
1952
|
+
if (lease != null) {
|
|
1953
|
+
Object.defineProperty(lease, 'startedAt', { value: serverTime });
|
|
1954
|
+
}
|
|
1955
|
+
return lease;
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 8n });
|
|
1959
|
+
const internal = compactor as any;
|
|
1960
|
+
let timeReads = 0;
|
|
1961
|
+
const clock = vi.spyOn(internal, 'readCompactionTime').mockImplementation(async () => {
|
|
1962
|
+
if (++timeReads > 5) {
|
|
1963
|
+
throw new Error('Compaction did not advance after the bucket aged');
|
|
1964
|
+
}
|
|
1965
|
+
return serverTime;
|
|
1966
|
+
});
|
|
1967
|
+
const scan = vi.spyOn(internal, 'findScheduledBucketBatch');
|
|
1968
|
+
const originalChunks = internal.compactSingleBucketChunks.bind(compactor);
|
|
1969
|
+
const originalFull = internal.compactSingleBucketFully.bind(compactor);
|
|
1970
|
+
const gate = Promise.withResolvers<void>();
|
|
1971
|
+
let activeChunks = 0;
|
|
1972
|
+
let activeFull = 0;
|
|
1973
|
+
let maxActiveFull = 0;
|
|
1974
|
+
const fullBuckets: string[] = [];
|
|
1975
|
+
const chunks = vi.spyOn(internal, 'compactSingleBucketChunks').mockImplementation(async (...args) => {
|
|
1976
|
+
activeChunks++;
|
|
1977
|
+
try {
|
|
1978
|
+
await gate.promise;
|
|
1979
|
+
return await originalChunks(...args);
|
|
1980
|
+
} finally {
|
|
1981
|
+
activeChunks--;
|
|
1982
|
+
}
|
|
1983
|
+
});
|
|
1984
|
+
const full = vi.spyOn(internal, 'compactSingleBucketFully').mockImplementation(async (...args) => {
|
|
1985
|
+
expect(activeChunks).toBe(0);
|
|
1986
|
+
activeFull++;
|
|
1987
|
+
maxActiveFull = Math.max(maxActiveFull, activeFull);
|
|
1988
|
+
fullBuckets.push((args[0] as any).state._id.b);
|
|
1989
|
+
try {
|
|
1990
|
+
return await originalFull(...args);
|
|
1991
|
+
} finally {
|
|
1992
|
+
activeFull--;
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
const run = compactor.compact();
|
|
1996
|
+
try {
|
|
1997
|
+
await vi.waitFor(() => expect(activeChunks).toBe(2));
|
|
1998
|
+
expect(full).not.toHaveBeenCalled();
|
|
1999
|
+
} finally {
|
|
2000
|
+
gate.resolve();
|
|
2001
|
+
try {
|
|
2002
|
+
await run;
|
|
2003
|
+
expect(timeReads).toBe(3); // Job start, first batch, and promoted bucket's batch.
|
|
2004
|
+
expect(scan).toHaveBeenCalledTimes(3); // Includes the final empty scan.
|
|
2005
|
+
for (const [cutoff] of scan.mock.calls) {
|
|
2006
|
+
expect(cutoff).toEqual(jobStartedAt);
|
|
2007
|
+
}
|
|
2008
|
+
} finally {
|
|
2009
|
+
clock.mockRestore();
|
|
2010
|
+
scan.mockRestore();
|
|
2011
|
+
claim.mockRestore();
|
|
2012
|
+
chunks.mockRestore();
|
|
2013
|
+
full.mockRestore();
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
expect(await run).toBe(5);
|
|
2017
|
+
expect(promoted).toBe(true);
|
|
2018
|
+
expect(maxActiveFull).toBe(1);
|
|
2019
|
+
expect(fullBuckets.sort()).toEqual(['full1[]', 'full2[]', 'promoted[]']);
|
|
2020
|
+
expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
|
|
2021
|
+
expect(await collection.countDocuments()).toBe(5);
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
|
|
2025
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2026
|
+
const operations = [
|
|
2027
|
+
makeOp(1, 'A', 'a', ctx, sourceTableId),
|
|
2028
|
+
makeOp(2, 'B', 'b', ctx, sourceTableId),
|
|
2029
|
+
makeOp(3, 'C', 'c', ctx, sourceTableId),
|
|
2030
|
+
makeOp(4, 'D', 'd', ctx, sourceTableId)
|
|
2031
|
+
];
|
|
2032
|
+
const originalDocuments = [
|
|
2033
|
+
serializeBucketData(BUCKET, operations.slice(0, 2)),
|
|
2034
|
+
serializeBucketData(BUCKET, operations.slice(2))
|
|
2035
|
+
];
|
|
2036
|
+
const committedMerge = serializeBucketData(BUCKET, operations);
|
|
2037
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
2038
|
+
await insertDocs(collection, [committedMerge, untouchedTail]);
|
|
2039
|
+
await bucketStateCollection.insertOne({
|
|
2040
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2041
|
+
last_op: 5n,
|
|
2042
|
+
next_compact_check: new Date(0),
|
|
2043
|
+
first_uncompacted_write: new Date(0),
|
|
2044
|
+
bucket_stats: {
|
|
2045
|
+
count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
|
|
2046
|
+
bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
|
|
2047
|
+
chunks: originalDocuments.length + 1
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
2052
|
+
|
|
2053
|
+
expect(result).toEqual({ buckets: 1 });
|
|
2054
|
+
const expectedStats = {
|
|
2055
|
+
count: committedMerge.count + untouchedTail.count,
|
|
2056
|
+
bytes: BigInt(committedMerge.size + untouchedTail.size),
|
|
2057
|
+
chunks: 2
|
|
2058
|
+
};
|
|
2059
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2060
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
2061
|
+
expect(state?.compacted_state).toMatchObject({
|
|
2062
|
+
op_id: 4n,
|
|
2063
|
+
checksum: committedMerge.checksum,
|
|
2064
|
+
count: committedMerge.count,
|
|
2065
|
+
bytes: BigInt(committedMerge.size),
|
|
2066
|
+
chunks: 1
|
|
2067
|
+
});
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
test('later full compaction claims fresh state after a committed replacement', async () => {
|
|
2071
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2072
|
+
const documents = [
|
|
2073
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
2074
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
2075
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
2076
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
2077
|
+
];
|
|
2078
|
+
await insertDocs(collection, documents);
|
|
2079
|
+
await bucketStateCollection.insertOne({
|
|
2080
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2081
|
+
last_op: 4n,
|
|
2082
|
+
next_compact_check: new Date(0),
|
|
2083
|
+
first_uncompacted_write: new Date(0),
|
|
2084
|
+
bucket_stats: {
|
|
2085
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
2086
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
2087
|
+
chunks: documents.length
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
2092
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
|
|
2093
|
+
const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
|
|
2094
|
+
let injectedFailure = false;
|
|
2095
|
+
vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
|
|
2096
|
+
const result = await originalFlush(...args);
|
|
2097
|
+
if (!injectedFailure) {
|
|
2098
|
+
injectedFailure = true;
|
|
2099
|
+
await insertDocs(collection, [concurrentDocument]);
|
|
2100
|
+
await bucketStateCollection.updateOne(
|
|
2101
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
2102
|
+
{
|
|
2103
|
+
$set: { last_op: 5n },
|
|
2104
|
+
$inc: {
|
|
2105
|
+
'bucket_stats.count': concurrentDocument.count,
|
|
2106
|
+
'bucket_stats.bytes': BigInt(concurrentDocument.size),
|
|
2107
|
+
'bucket_stats.chunks': 1
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
);
|
|
2111
|
+
throw new ObjectStorageError('failure after committed full-compaction replacement', {
|
|
2112
|
+
cause: new Error('socket reset'),
|
|
2113
|
+
retryable: true
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
return result;
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
|
|
2120
|
+
|
|
2121
|
+
expect(injectedFailure).toBe(true);
|
|
2122
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2123
|
+
expect(interruptedState?.last_op).toBe(5n);
|
|
2124
|
+
expect(interruptedState?.compacted_state).toBeUndefined();
|
|
2125
|
+
expect(interruptedState?.compact_lease).toBeUndefined();
|
|
2126
|
+
|
|
2127
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
|
|
2128
|
+
1
|
|
2129
|
+
);
|
|
2130
|
+
|
|
2131
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2132
|
+
expect(currentDocuments).toHaveLength(1);
|
|
2133
|
+
const expectedStats = {
|
|
2134
|
+
count: currentDocuments.reduce((total, document) => total + document.count, 0),
|
|
2135
|
+
bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
|
|
2136
|
+
chunks: currentDocuments.length
|
|
2137
|
+
};
|
|
2138
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2139
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
2140
|
+
expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
|
|
2141
|
+
expect(state?.last_full_compact?.op_id).toBe(5n);
|
|
2142
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
2143
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
test('chunk compaction treats a missing cached op as a resume hint', async () => {
|
|
2147
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2148
|
+
const documents = [
|
|
2149
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
2150
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
2151
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
2152
|
+
];
|
|
2153
|
+
await insertDocs(collection, documents);
|
|
2154
|
+
await bucketStateCollection.insertOne({
|
|
2155
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
2156
|
+
last_op: 4n,
|
|
2157
|
+
next_compact_check: new Date(0),
|
|
2158
|
+
first_uncompacted_write: new Date(0),
|
|
2159
|
+
compacted_state: {
|
|
2160
|
+
op_id: 2n,
|
|
2161
|
+
checksum: 21n,
|
|
2162
|
+
count: 2,
|
|
2163
|
+
bytes: 100n,
|
|
2164
|
+
chunks: 2,
|
|
2165
|
+
at: new Date(0)
|
|
2166
|
+
},
|
|
2167
|
+
bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
|
|
2171
|
+
1
|
|
2172
|
+
);
|
|
2173
|
+
|
|
2174
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
2175
|
+
expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
|
|
2176
|
+
const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
|
|
2177
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2178
|
+
expect(state?.compacted_state).toMatchObject({
|
|
2179
|
+
op_id: 4n,
|
|
2180
|
+
checksum: 56n,
|
|
2181
|
+
count: 3,
|
|
2182
|
+
bytes,
|
|
2183
|
+
chunks: currentDocuments.length
|
|
2184
|
+
});
|
|
2185
|
+
expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
test('later chunk compaction rebuilds state after a committed partial merge', async () => {
|
|
2189
|
+
const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
|
|
2190
|
+
const failedCompactor = bucketStorage.createMongoCompactor({
|
|
2191
|
+
maxOpId: 4n,
|
|
2192
|
+
compactChunksOnly: true
|
|
2193
|
+
});
|
|
2194
|
+
const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
|
|
2195
|
+
const flushSpy = vi
|
|
2196
|
+
.spyOn(failedCompactor as any, 'flushCompactionGroup')
|
|
2197
|
+
.mockImplementation(async (...args: any[]) => {
|
|
2198
|
+
const result = await originalFlush(...args);
|
|
2199
|
+
throw new Error('failure after committed chunk merge');
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
await expect(failedCompactor.compact()).resolves.toBe(0);
|
|
2203
|
+
flushSpy.mockRestore();
|
|
2204
|
+
|
|
2205
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2206
|
+
expect(interruptedState?.compacted_state?.op_id).toBe(2n);
|
|
2207
|
+
expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
|
|
2208
|
+
await bucketStateCollection.updateOne(
|
|
2209
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
2210
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
|
|
2214
|
+
|
|
2215
|
+
expect(result).toBe(1);
|
|
2216
|
+
await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
|
|
2217
|
+
});
|
|
2218
|
+
|
|
1504
2219
|
test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
|
|
1505
2220
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1506
2221
|
|
|
@@ -1616,6 +2331,13 @@ bucket_definitions:
|
|
|
1616
2331
|
expect(op600).toBeDefined();
|
|
1617
2332
|
expect(op600!.op).toBe('PUT');
|
|
1618
2333
|
expect(op600!.row_id).toBe('F');
|
|
2334
|
+
|
|
2335
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2336
|
+
expect(state?.bucket_stats).toEqual({
|
|
2337
|
+
count: docsAfter.reduce((total, document) => total + document.count, 0),
|
|
2338
|
+
bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
|
|
2339
|
+
chunks: docsAfter.length
|
|
2340
|
+
});
|
|
1619
2341
|
});
|
|
1620
2342
|
|
|
1621
2343
|
test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
|