@powersync/service-module-mongodb-storage 0.19.0 → 0.21.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 +64 -0
- package/dist/storage/MongoBucketStorage.d.ts +31 -3
- package/dist/storage/MongoBucketStorage.js +365 -206
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
- package/dist/storage/implementation/MongoBucketBatch.js +35 -9
- 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 +3 -1
- 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/MongoWriteCheckpointAPI.d.ts +9 -7
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
- 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 +27 -0
- package/dist/storage/implementation/common/PersistedBatch.js +43 -2
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +4 -1
- package/dist/storage/implementation/db.js +14 -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 +6 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +80 -84
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
- package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +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/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 +8 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +120 -39
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
- package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
- 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 +2 -0
- package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
- 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 +89 -5
- 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 +8 -4
- 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 +3 -5
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
- 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 +23 -1
- package/dist/types/types.js +16 -7
- package/dist/types/types.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 +495 -268
- package/src/storage/implementation/MongoBucketBatch.ts +54 -14
- 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 +3 -1
- package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
- package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
- package/src/storage/implementation/common/PersistedBatch.ts +54 -2
- package/src/storage/implementation/db.ts +21 -2
- package/src/storage/implementation/models.ts +38 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +107 -96
- package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
- 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 +159 -42
- package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
- 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 +57 -12
- 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 +96 -3
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
- package/src/storage/implementation/v3/source-table-utils.ts +82 -76
- package/src/storage/storage-index.ts +2 -0
- package/src/types/types.ts +24 -7
- 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/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 +255 -0
- package/test/src/parameter_compacting_v1.test.ts +120 -0
- package/test/src/parameter_compacting_v3.test.ts +192 -0
- package/test/src/parameter_compaction_fence.test.ts +276 -0
- package/test/src/storage.test.ts +273 -22
- package/test/src/storage_compacting.test.ts +714 -244
- 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 +243 -1
- package/test/src/storage_s3_writing.test.ts +1 -0
- package/test/src/storage_sync.test.ts +365 -41
- package/test/src/util.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,8 +2,13 @@ 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 { replicaIdToSubkey } from '@module/utils/util.js';
|
|
11
|
+
import { logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
7
12
|
import {
|
|
8
13
|
addChecksums,
|
|
9
14
|
CheckpointChecksumInvalidatedError,
|
|
@@ -11,10 +16,37 @@ import {
|
|
|
11
16
|
SyncRulesBucketStorage,
|
|
12
17
|
updateSyncRulesFromYaml
|
|
13
18
|
} from '@powersync/service-core';
|
|
14
|
-
import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
|
|
19
|
+
import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
|
|
15
20
|
import * as bson from 'bson';
|
|
16
|
-
import { describe, expect, test } from 'vitest';
|
|
17
|
-
import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
|
|
21
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
22
|
+
import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
|
|
23
|
+
|
|
24
|
+
function makeOp(
|
|
25
|
+
opId: number,
|
|
26
|
+
rowId: string,
|
|
27
|
+
data: string,
|
|
28
|
+
ctx: { replicationStreamId: number; definitionId: string; bucket: string },
|
|
29
|
+
sourceTableId: bson.ObjectId,
|
|
30
|
+
overrides?: { op?: 'PUT' | 'REMOVE' }
|
|
31
|
+
): BucketDataDoc {
|
|
32
|
+
const sourceKey = test_utils.rid(rowId);
|
|
33
|
+
return {
|
|
34
|
+
bucketKey: {
|
|
35
|
+
replicationStreamId: ctx.replicationStreamId,
|
|
36
|
+
definitionId: ctx.definitionId,
|
|
37
|
+
bucket: ctx.bucket
|
|
38
|
+
},
|
|
39
|
+
o: BigInt(opId),
|
|
40
|
+
op: overrides?.op ?? 'PUT',
|
|
41
|
+
source_table: sourceTableId,
|
|
42
|
+
source_key: sourceKey,
|
|
43
|
+
subkey: replicaIdToSubkey(sourceTableId, sourceKey),
|
|
44
|
+
table: 'items',
|
|
45
|
+
row_id: rowId,
|
|
46
|
+
checksum: BigInt(opId * 7),
|
|
47
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
48
|
+
};
|
|
49
|
+
}
|
|
18
50
|
|
|
19
51
|
describe('Mongo Sync Bucket Storage Compact', () => {
|
|
20
52
|
register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
@@ -58,15 +90,18 @@ describe('Mongo Sync Bucket Storage Compact', () => {
|
|
|
58
90
|
return bucketStorage.getCheckpoint();
|
|
59
91
|
};
|
|
60
92
|
|
|
61
|
-
const setup = async () => {
|
|
93
|
+
const setup = async (storageVersion?: number) => {
|
|
62
94
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
63
95
|
const syncRules = await factory.updateSyncRules(
|
|
64
|
-
updateSyncRulesFromYaml(
|
|
96
|
+
updateSyncRulesFromYaml(
|
|
97
|
+
`
|
|
65
98
|
bucket_definitions:
|
|
66
99
|
by_user:
|
|
67
100
|
parameters: select request.user_id() as user_id
|
|
68
101
|
data: [select * from test where owner_id = bucket.user_id]
|
|
69
|
-
|
|
102
|
+
`,
|
|
103
|
+
{ storageVersion }
|
|
104
|
+
)
|
|
70
105
|
);
|
|
71
106
|
const bucketStorage = factory.getInstance(syncRules);
|
|
72
107
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
@@ -75,20 +110,17 @@ bucket_definitions:
|
|
|
75
110
|
return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
|
|
76
111
|
};
|
|
77
112
|
|
|
78
|
-
test('full compact', async () => {
|
|
113
|
+
test('V1 full compact with blank bucket_state', async () => {
|
|
79
114
|
const { bucketStorage, checkpoint, factory, syncRules } = await setup();
|
|
80
115
|
const storageDb = bucketStorage.db;
|
|
81
116
|
|
|
82
|
-
// Simulate bucket_state from old version not being available
|
|
83
117
|
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({});
|
|
118
|
+
return;
|
|
89
119
|
}
|
|
120
|
+
// Simulate a V1 deployment which pre-dates bucket-state population.
|
|
121
|
+
await factory.db.bucket_state.deleteMany({});
|
|
90
122
|
|
|
91
|
-
await
|
|
123
|
+
await compactActive(factory, {
|
|
92
124
|
clearBatchLimit: 200,
|
|
93
125
|
moveBatchLimit: 10,
|
|
94
126
|
moveBatchQueryLimit: 10,
|
|
@@ -113,18 +145,21 @@ bucket_definitions:
|
|
|
113
145
|
});
|
|
114
146
|
});
|
|
115
147
|
|
|
116
|
-
test('
|
|
148
|
+
test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
|
|
117
149
|
// Populate old replication stream
|
|
118
|
-
const { factory } = await setup();
|
|
150
|
+
const { factory } = await setup(storageVersion);
|
|
119
151
|
|
|
120
152
|
// Now populate another replication stream (bucket definition name changed)
|
|
121
153
|
const syncRules = await factory.updateSyncRules(
|
|
122
|
-
updateSyncRulesFromYaml(
|
|
154
|
+
updateSyncRulesFromYaml(
|
|
155
|
+
`
|
|
123
156
|
bucket_definitions:
|
|
124
157
|
by_user2:
|
|
125
158
|
parameters: select request.user_id() as user_id
|
|
126
159
|
data: [select * from test where owner_id = bucket.user_id]
|
|
127
|
-
|
|
160
|
+
`,
|
|
161
|
+
{ storageVersion }
|
|
162
|
+
)
|
|
128
163
|
);
|
|
129
164
|
const bucketStorage = factory.getInstance(syncRules);
|
|
130
165
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
@@ -132,21 +167,23 @@ bucket_definitions:
|
|
|
132
167
|
await populate(bucketStorage, 2);
|
|
133
168
|
const { checkpoint } = await bucketStorage.getCheckpoint();
|
|
134
169
|
|
|
135
|
-
//
|
|
136
|
-
|
|
170
|
+
// V3's initial lite pass processes every bucket with no prior compact state.
|
|
171
|
+
// Earlier storage versions use the default minimum-change threshold.
|
|
172
|
+
const result0 = await bucketStorage.compactInitialReplication({
|
|
137
173
|
maxOpId: checkpoint
|
|
138
174
|
});
|
|
139
|
-
expect(result0.buckets).toEqual(0);
|
|
175
|
+
expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
|
|
140
176
|
|
|
141
|
-
//
|
|
142
|
-
|
|
177
|
+
// For V1/V2, lower the threshold to populate the checksum cache. V3 has
|
|
178
|
+
// already updated its compacted state, so another initial pass is a no-op.
|
|
179
|
+
const result1 = await bucketStorage.compactInitialReplication({
|
|
143
180
|
maxOpId: checkpoint,
|
|
144
181
|
minBucketChanges: 1
|
|
145
182
|
});
|
|
146
|
-
expect(result1.buckets).toEqual(2);
|
|
183
|
+
expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
|
|
147
184
|
|
|
148
|
-
//
|
|
149
|
-
const result2 = await bucketStorage.
|
|
185
|
+
// Repeating it stays a no-op.
|
|
186
|
+
const result2 = await bucketStorage.compactInitialReplication({
|
|
150
187
|
maxOpId: checkpoint,
|
|
151
188
|
minBucketChanges: 1
|
|
152
189
|
});
|
|
@@ -168,88 +205,82 @@ bucket_definitions:
|
|
|
168
205
|
});
|
|
169
206
|
});
|
|
170
207
|
|
|
171
|
-
test('
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
208
|
+
test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
|
|
209
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
210
|
+
const result = await (bucketStorage as MongoSyncBucketStorage)
|
|
211
|
+
.createMongoCompactor({
|
|
212
|
+
maxOpId: checkpoint,
|
|
213
|
+
compactChunksOnly: true,
|
|
214
|
+
minCompactChunkIntervalMs: 1
|
|
215
|
+
})
|
|
216
|
+
.compact();
|
|
217
|
+
|
|
218
|
+
expect(result).toBe(2);
|
|
219
|
+
});
|
|
182
220
|
|
|
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
|
-
}
|
|
221
|
+
test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
|
|
222
|
+
const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
|
|
223
|
+
const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
|
|
224
|
+
expect(firstResult.buckets).toBe(2);
|
|
226
225
|
|
|
227
|
-
|
|
228
|
-
|
|
226
|
+
const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
|
|
227
|
+
bucketStorage.replicationStreamId
|
|
228
|
+
);
|
|
229
|
+
await bucketStateCollection.updateMany(
|
|
230
|
+
{},
|
|
231
|
+
{
|
|
232
|
+
$set: {
|
|
233
|
+
first_uncompacted_write: new Date(0),
|
|
234
|
+
next_compact_check: new Date(0)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
);
|
|
229
238
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
|
|
240
|
+
.aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
|
|
241
|
+
.toArray();
|
|
242
|
+
const result = await bucketStorage.compactInitialReplication({
|
|
243
|
+
maxOpId: checkpoint,
|
|
244
|
+
signal: AbortSignal.timeout(2_000)
|
|
233
245
|
});
|
|
234
|
-
const firstBatch = await dirtyBuckets.next();
|
|
235
246
|
|
|
236
|
-
expect(
|
|
237
|
-
|
|
238
|
-
expect(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
expect(result.buckets).toBe(0);
|
|
248
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
249
|
+
expect(states).toHaveLength(2);
|
|
250
|
+
for (const state of states) {
|
|
251
|
+
expect(state.next_compact_check!.getTime()).toBeGreaterThan(
|
|
252
|
+
now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
242
256
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
257
|
+
test('v3 replication writes initialize scheduled compaction state', async () => {
|
|
258
|
+
const { bucketStorage } = await setup();
|
|
259
|
+
const storageDb = bucketStorage.db;
|
|
260
|
+
|
|
261
|
+
if (!storageDb.storageConfig.incrementalReprocessing) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
|
|
265
|
+
bucketStorage.replicationStreamId
|
|
266
|
+
);
|
|
267
|
+
const states = await bucketStateCollection.find({}).toArray();
|
|
268
|
+
expect(states).toHaveLength(2);
|
|
269
|
+
for (const state of states) {
|
|
270
|
+
expect(state.last_op).toBeGreaterThan(0n);
|
|
271
|
+
expect(state.bucket_stats.count).toBe(1);
|
|
272
|
+
expect(state.bucket_stats.chunks).toBe(1);
|
|
273
|
+
expect(state.first_uncompacted_write).toBeInstanceOf(Date);
|
|
274
|
+
expect(state.next_compact_check).toBeInstanceOf(Date);
|
|
275
|
+
expect(state.compacted_state).toBeUndefined();
|
|
276
|
+
expect(state).not.toHaveProperty('estimate_since_compact');
|
|
277
|
+
|
|
278
|
+
const docs = await (storageDb as VersionedPowerSyncMongoV3)
|
|
279
|
+
.bucketData(bucketStorage.replicationStreamId, state._id.d)
|
|
280
|
+
.find({ '_id.b': state._id.b })
|
|
281
|
+
.toArray();
|
|
282
|
+
expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
|
|
283
|
+
}
|
|
253
284
|
});
|
|
254
285
|
});
|
|
255
286
|
});
|
|
@@ -284,14 +315,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
|
|
|
284
315
|
*/
|
|
285
316
|
describe('V3 invariant verification', () => {
|
|
286
317
|
const BUCKET = 'global[]';
|
|
287
|
-
const TABLE = 'items';
|
|
288
318
|
|
|
289
319
|
function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
|
|
320
|
+
const sourceTable = new bson.ObjectId();
|
|
321
|
+
const sourceKey = 'key';
|
|
290
322
|
return {
|
|
291
323
|
bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
|
|
292
324
|
op: 'PUT',
|
|
293
|
-
source_table:
|
|
294
|
-
source_key:
|
|
325
|
+
source_table: sourceTable,
|
|
326
|
+
source_key: sourceKey,
|
|
327
|
+
subkey: replicaIdToSubkey(sourceTable, sourceKey),
|
|
295
328
|
table: 'test',
|
|
296
329
|
row_id: 'row1',
|
|
297
330
|
checksum: 1n,
|
|
@@ -329,31 +362,6 @@ bucket_definitions:
|
|
|
329
362
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
|
|
330
363
|
}
|
|
331
364
|
|
|
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
365
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
358
366
|
await collection.insertMany(docs);
|
|
359
367
|
}
|
|
@@ -362,7 +370,9 @@ bucket_definitions:
|
|
|
362
370
|
await bucketStateCollection.insertOne({
|
|
363
371
|
_id: { d: definitionId, b: BUCKET },
|
|
364
372
|
last_op: lastOp,
|
|
365
|
-
|
|
373
|
+
next_compact_check: new Date(0),
|
|
374
|
+
first_uncompacted_write: new Date(0),
|
|
375
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
366
376
|
});
|
|
367
377
|
}
|
|
368
378
|
|
|
@@ -371,12 +381,133 @@ bucket_definitions:
|
|
|
371
381
|
clearBatchLimit: 200,
|
|
372
382
|
moveBatchLimit: 10,
|
|
373
383
|
moveBatchQueryLimit: 10,
|
|
374
|
-
minBucketChanges: 1,
|
|
375
|
-
minChangeRatio: 0,
|
|
376
384
|
maxOpId
|
|
377
385
|
});
|
|
378
386
|
}
|
|
379
387
|
|
|
388
|
+
test('a successful lease renewal clears a transient renewal error', async () => {
|
|
389
|
+
const { bucketStateCollection, ctx } = await setupV3Storage();
|
|
390
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
|
|
391
|
+
const lease = await CompactionLease.claim(
|
|
392
|
+
bucketStateCollection,
|
|
393
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
394
|
+
undefined,
|
|
395
|
+
10 * 60 * 1000
|
|
396
|
+
);
|
|
397
|
+
expect(lease).not.toBeNull();
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const transientError = new Error('temporary MongoDB error');
|
|
401
|
+
const renew = (lease as any).renew.bind(lease);
|
|
402
|
+
const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
|
|
403
|
+
await renew().catch((error: unknown) => {
|
|
404
|
+
(lease as any).renewalError = error;
|
|
405
|
+
});
|
|
406
|
+
await expect(lease!.throwIfLost()).rejects.toBe(transientError);
|
|
407
|
+
|
|
408
|
+
updateOne.mockRestore();
|
|
409
|
+
await renew();
|
|
410
|
+
await expect(lease!.throwIfLost()).resolves.toBeUndefined();
|
|
411
|
+
} finally {
|
|
412
|
+
await lease?.[Symbol.asyncDispose]();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('a failed scheduled bucket does not block other scheduled buckets', async () => {
|
|
417
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
418
|
+
const badBucket = 'bad[]';
|
|
419
|
+
const goodBucket = 'good[]';
|
|
420
|
+
const goodDocument = serializeBucketData(goodBucket, [
|
|
421
|
+
makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
|
|
422
|
+
]);
|
|
423
|
+
await insertDocs(collection, [goodDocument]);
|
|
424
|
+
await bucketStateCollection.insertMany([
|
|
425
|
+
{
|
|
426
|
+
_id: { d: ctx.definitionId, b: badBucket },
|
|
427
|
+
last_op: 2n,
|
|
428
|
+
next_compact_check: new Date(0),
|
|
429
|
+
// A malformed scheduled bucket with an expired lease must be
|
|
430
|
+
// rescheduled without preventing valid buckets from compacting.
|
|
431
|
+
first_uncompacted_write: new Date(0),
|
|
432
|
+
bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
|
|
433
|
+
compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
_id: { d: ctx.definitionId, b: goodBucket },
|
|
437
|
+
last_op: 2n,
|
|
438
|
+
next_compact_check: new Date(0),
|
|
439
|
+
first_uncompacted_write: new Date(0),
|
|
440
|
+
bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
|
|
441
|
+
}
|
|
442
|
+
]);
|
|
443
|
+
await bucketStateCollection.updateOne(
|
|
444
|
+
{ _id: { d: ctx.definitionId, b: badBucket } },
|
|
445
|
+
{ $unset: { first_uncompacted_write: '' } }
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await (bucketStorage as MongoSyncBucketStorage)
|
|
449
|
+
.createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
|
|
450
|
+
.compact();
|
|
451
|
+
|
|
452
|
+
const [badState, goodState] = await Promise.all([
|
|
453
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
|
|
454
|
+
bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
|
|
455
|
+
]);
|
|
456
|
+
expect(badState?.next_compact_check).toBeInstanceOf(Date);
|
|
457
|
+
expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
458
|
+
expect(goodState?.compacted_state?.op_id).toBe(2n);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
|
|
462
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
463
|
+
const buckets = ['first[]', 'second[]', 'third[]'];
|
|
464
|
+
const documents = buckets.map((bucket) =>
|
|
465
|
+
serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
|
|
466
|
+
);
|
|
467
|
+
await insertDocs(collection, documents);
|
|
468
|
+
await bucketStateCollection.insertMany(
|
|
469
|
+
documents.map((document, index) => ({
|
|
470
|
+
_id: { d: ctx.definitionId, b: buckets[index] },
|
|
471
|
+
last_op: 2n,
|
|
472
|
+
next_compact_check: new Date(0),
|
|
473
|
+
first_uncompacted_write: new Date(0),
|
|
474
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
475
|
+
}))
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const abortController = new AbortController();
|
|
479
|
+
const testLogger = defaultLogger.child({});
|
|
480
|
+
vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
|
|
481
|
+
if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
|
|
482
|
+
abortController.abort();
|
|
483
|
+
}
|
|
484
|
+
return testLogger;
|
|
485
|
+
});
|
|
486
|
+
const errorLog = vi.spyOn(testLogger, 'error');
|
|
487
|
+
|
|
488
|
+
await expect(
|
|
489
|
+
bucketStorage
|
|
490
|
+
.createMongoCompactor({
|
|
491
|
+
maxOpId: 2n,
|
|
492
|
+
compactChunksOnly: true,
|
|
493
|
+
signal: abortController.signal,
|
|
494
|
+
logger: testLogger
|
|
495
|
+
})
|
|
496
|
+
.compact()
|
|
497
|
+
).rejects.toThrow();
|
|
498
|
+
|
|
499
|
+
const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
|
|
500
|
+
const completed = states.filter((state) => state.compacted_state != null);
|
|
501
|
+
const remaining = states.filter((state) => state.compacted_state == null);
|
|
502
|
+
expect(completed).toHaveLength(1);
|
|
503
|
+
expect(remaining).toHaveLength(2);
|
|
504
|
+
for (const state of remaining) {
|
|
505
|
+
expect(state.next_compact_check).toEqual(new Date(0));
|
|
506
|
+
expect(state.compact_lease).toBeUndefined();
|
|
507
|
+
}
|
|
508
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
380
511
|
test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
|
|
381
512
|
const ops = [
|
|
382
513
|
makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
|
|
@@ -746,7 +877,6 @@ bucket_definitions:
|
|
|
746
877
|
|
|
747
878
|
describe('V3 checksum pipeline straddling', () => {
|
|
748
879
|
const BUCKET = 'global[]';
|
|
749
|
-
const TABLE = 'items';
|
|
750
880
|
|
|
751
881
|
async function setup() {
|
|
752
882
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -776,30 +906,6 @@ bucket_definitions:
|
|
|
776
906
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
|
|
777
907
|
}
|
|
778
908
|
|
|
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
909
|
function checksumRequest(): storage.BucketChecksumRequest {
|
|
804
910
|
return {
|
|
805
911
|
bucket: BUCKET,
|
|
@@ -835,7 +941,9 @@ bucket_definitions:
|
|
|
835
941
|
await bucketStateCollection.insertOne({
|
|
836
942
|
_id: { d: definitionId, b: BUCKET },
|
|
837
943
|
last_op: 30n,
|
|
838
|
-
|
|
944
|
+
next_compact_check: undefined,
|
|
945
|
+
first_uncompacted_write: undefined,
|
|
946
|
+
bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
|
|
839
947
|
});
|
|
840
948
|
|
|
841
949
|
const request = checksumRequest();
|
|
@@ -874,7 +982,9 @@ bucket_definitions:
|
|
|
874
982
|
await bucketStateCollection.insertOne({
|
|
875
983
|
_id: { d: definitionId, b: BUCKET },
|
|
876
984
|
last_op: 0n,
|
|
877
|
-
|
|
985
|
+
next_compact_check: undefined,
|
|
986
|
+
first_uncompacted_write: undefined,
|
|
987
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
878
988
|
});
|
|
879
989
|
|
|
880
990
|
const request = checksumRequest();
|
|
@@ -894,7 +1004,9 @@ bucket_definitions:
|
|
|
894
1004
|
await bucketStateCollection.insertOne({
|
|
895
1005
|
_id: { d: definitionId, b: BUCKET },
|
|
896
1006
|
last_op: 0n,
|
|
897
|
-
|
|
1007
|
+
next_compact_check: undefined,
|
|
1008
|
+
first_uncompacted_write: undefined,
|
|
1009
|
+
bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
|
|
898
1010
|
});
|
|
899
1011
|
|
|
900
1012
|
const request = checksumRequest();
|
|
@@ -913,7 +1025,9 @@ bucket_definitions:
|
|
|
913
1025
|
await bucketStateCollection.insertOne({
|
|
914
1026
|
_id: { d: definitionId, b: BUCKET },
|
|
915
1027
|
last_op: 10n,
|
|
916
|
-
|
|
1028
|
+
next_compact_check: undefined,
|
|
1029
|
+
first_uncompacted_write: undefined,
|
|
1030
|
+
bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
|
|
917
1031
|
});
|
|
918
1032
|
|
|
919
1033
|
const request = checksumRequest();
|
|
@@ -926,7 +1040,7 @@ bucket_definitions:
|
|
|
926
1040
|
await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
|
|
927
1041
|
await bucketStateCollection.updateOne(
|
|
928
1042
|
{ _id: { d: definitionId, b: BUCKET } },
|
|
929
|
-
{ $set: { last_op: 30n, '
|
|
1043
|
+
{ $set: { last_op: 30n, 'bucket_stats.count': 2 } }
|
|
930
1044
|
);
|
|
931
1045
|
|
|
932
1046
|
const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
@@ -940,7 +1054,6 @@ bucket_definitions:
|
|
|
940
1054
|
|
|
941
1055
|
describe('V3 compaction boundaries', () => {
|
|
942
1056
|
const BUCKET = 'global[]';
|
|
943
|
-
const TABLE = 'items';
|
|
944
1057
|
|
|
945
1058
|
async function setupV3() {
|
|
946
1059
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -971,31 +1084,6 @@ bucket_definitions:
|
|
|
971
1084
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
972
1085
|
}
|
|
973
1086
|
|
|
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
1087
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1000
1088
|
await collection.insertMany(docs);
|
|
1001
1089
|
}
|
|
@@ -1004,7 +1092,9 @@ bucket_definitions:
|
|
|
1004
1092
|
await bucketStateCollection.insertOne({
|
|
1005
1093
|
_id: { d: definitionId, b: BUCKET },
|
|
1006
1094
|
last_op: lastOp,
|
|
1007
|
-
|
|
1095
|
+
next_compact_check: new Date(0),
|
|
1096
|
+
first_uncompacted_write: new Date(0),
|
|
1097
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1008
1098
|
});
|
|
1009
1099
|
}
|
|
1010
1100
|
|
|
@@ -1030,6 +1120,156 @@ bucket_definitions:
|
|
|
1030
1120
|
return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1031
1121
|
}
|
|
1032
1122
|
|
|
1123
|
+
test('scheduled compaction claims only due buckets and clears completed full work', async () => {
|
|
1124
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1125
|
+
const docs = [
|
|
1126
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
|
|
1127
|
+
serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
|
|
1128
|
+
];
|
|
1129
|
+
await insertDocs(collection, docs);
|
|
1130
|
+
await bucketStateCollection.insertOne({
|
|
1131
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1132
|
+
last_op: 2n,
|
|
1133
|
+
next_compact_check: new Date(Date.now() + 60_000),
|
|
1134
|
+
first_uncompacted_write: new Date(Date.now() - 60_000),
|
|
1135
|
+
bucket_stats: {
|
|
1136
|
+
count: 2,
|
|
1137
|
+
bytes: BigInt(docs[0].size + docs[1].size),
|
|
1138
|
+
chunks: 2
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1143
|
+
expect(
|
|
1144
|
+
(await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
|
|
1145
|
+
).toBeUndefined();
|
|
1146
|
+
|
|
1147
|
+
await bucketStateCollection.updateOne(
|
|
1148
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1149
|
+
{ $set: { next_compact_check: new Date(Date.now() - 1) } }
|
|
1150
|
+
);
|
|
1151
|
+
await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
|
|
1152
|
+
|
|
1153
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1154
|
+
expect(state?.last_full_compact?.op_id).toBe(2n);
|
|
1155
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1156
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1157
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
|
|
1161
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1162
|
+
const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
|
|
1163
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
|
|
1164
|
+
await insertDocs(collection, [prefix, untouchedTail]);
|
|
1165
|
+
await bucketStateCollection.insertOne({
|
|
1166
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1167
|
+
last_op: 2n,
|
|
1168
|
+
next_compact_check: new Date(0),
|
|
1169
|
+
first_uncompacted_write: new Date(0),
|
|
1170
|
+
bucket_stats: {
|
|
1171
|
+
count: prefix.count + untouchedTail.count,
|
|
1172
|
+
bytes: BigInt(prefix.size + untouchedTail.size),
|
|
1173
|
+
chunks: 2
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1178
|
+
|
|
1179
|
+
const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1180
|
+
expect(partialState?.last_full_compact).toMatchObject({
|
|
1181
|
+
op_id: 1n,
|
|
1182
|
+
count: prefix.count,
|
|
1183
|
+
puts: 1
|
|
1184
|
+
});
|
|
1185
|
+
expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
|
|
1186
|
+
expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
|
|
1187
|
+
expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
|
|
1188
|
+
partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
const lastFullCompactAt = partialState!.last_full_compact!.at;
|
|
1192
|
+
const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
|
|
1193
|
+
await bucketStateCollection.updateOne(
|
|
1194
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1195
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
await bucketStorage.compact({ maxOpId: 2n });
|
|
1199
|
+
|
|
1200
|
+
const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1201
|
+
expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1202
|
+
expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
|
|
1203
|
+
expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
|
|
1204
|
+
|
|
1205
|
+
// Even after the scheduling window has elapsed, a run with the old cap
|
|
1206
|
+
// must not scan and record the same full-compaction prefix again.
|
|
1207
|
+
await bucketStateCollection.updateOne(
|
|
1208
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1209
|
+
{ $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
await bucketStorage.compact({ maxOpId: 1n });
|
|
1213
|
+
|
|
1214
|
+
const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1215
|
+
expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
|
|
1216
|
+
expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
|
|
1217
|
+
expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
|
|
1221
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1222
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1223
|
+
await insertDocs(collection, [document]);
|
|
1224
|
+
await bucketStateCollection.insertOne({
|
|
1225
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1226
|
+
last_op: 1n,
|
|
1227
|
+
next_compact_check: undefined,
|
|
1228
|
+
first_uncompacted_write: undefined,
|
|
1229
|
+
compacted_state: {
|
|
1230
|
+
op_id: 1n,
|
|
1231
|
+
checksum: document.checksum,
|
|
1232
|
+
count: document.count,
|
|
1233
|
+
bytes: BigInt(document.size),
|
|
1234
|
+
chunks: 1,
|
|
1235
|
+
at: new Date()
|
|
1236
|
+
},
|
|
1237
|
+
last_full_compact: {
|
|
1238
|
+
op_id: 1n,
|
|
1239
|
+
count: document.count,
|
|
1240
|
+
puts: 1,
|
|
1241
|
+
at: new Date()
|
|
1242
|
+
},
|
|
1243
|
+
bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
|
|
1247
|
+
|
|
1248
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1249
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1250
|
+
expect(state?.next_compact_check).toBeNull();
|
|
1251
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('concurrent scheduled compactors lease a bucket to one worker', async () => {
|
|
1255
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1256
|
+
const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
|
|
1257
|
+
await insertDocs(collection, [document]);
|
|
1258
|
+
await bucketStateCollection.insertOne({
|
|
1259
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1260
|
+
last_op: 1n,
|
|
1261
|
+
next_compact_check: new Date(Date.now() - 1),
|
|
1262
|
+
first_uncompacted_write: new Date(0),
|
|
1263
|
+
bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
|
|
1267
|
+
|
|
1268
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1269
|
+
expect(state?.last_full_compact?.op_id).toBe(1n);
|
|
1270
|
+
expect(state?.compact_lease).toBeUndefined();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1033
1273
|
test('1. superseded ops become MOVE tombstones', async () => {
|
|
1034
1274
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1035
1275
|
// Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
|
|
@@ -1207,7 +1447,6 @@ bucket_definitions:
|
|
|
1207
1447
|
|
|
1208
1448
|
describe('V3 MOVE tombstone properties', () => {
|
|
1209
1449
|
const BUCKET = 'global[]';
|
|
1210
|
-
const TABLE = 'items';
|
|
1211
1450
|
|
|
1212
1451
|
async function setupV3() {
|
|
1213
1452
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -1238,31 +1477,6 @@ bucket_definitions:
|
|
|
1238
1477
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1239
1478
|
}
|
|
1240
1479
|
|
|
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
1480
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1267
1481
|
await collection.insertMany(docs);
|
|
1268
1482
|
}
|
|
@@ -1271,7 +1485,9 @@ bucket_definitions:
|
|
|
1271
1485
|
await bucketStateCollection.insertOne({
|
|
1272
1486
|
_id: { d: definitionId, b: BUCKET },
|
|
1273
1487
|
last_op: lastOp,
|
|
1274
|
-
|
|
1488
|
+
next_compact_check: new Date(0),
|
|
1489
|
+
first_uncompacted_write: new Date(0),
|
|
1490
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1275
1491
|
});
|
|
1276
1492
|
}
|
|
1277
1493
|
|
|
@@ -1420,7 +1636,6 @@ bucket_definitions:
|
|
|
1420
1636
|
*/
|
|
1421
1637
|
describe('Streaming compactor', () => {
|
|
1422
1638
|
const BUCKET = 'global[]';
|
|
1423
|
-
const TABLE = 'items';
|
|
1424
1639
|
|
|
1425
1640
|
async function setupV3() {
|
|
1426
1641
|
await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
|
|
@@ -1450,31 +1665,6 @@ bucket_definitions:
|
|
|
1450
1665
|
return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
|
|
1451
1666
|
}
|
|
1452
1667
|
|
|
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
1668
|
async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
|
|
1479
1669
|
await collection.insertMany(docs);
|
|
1480
1670
|
}
|
|
@@ -1483,7 +1673,9 @@ bucket_definitions:
|
|
|
1483
1673
|
await bucketStateCollection.insertOne({
|
|
1484
1674
|
_id: { d: definitionId, b: BUCKET },
|
|
1485
1675
|
last_op: lastOp,
|
|
1486
|
-
|
|
1676
|
+
next_compact_check: new Date(0),
|
|
1677
|
+
first_uncompacted_write: new Date(0),
|
|
1678
|
+
bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
|
|
1487
1679
|
});
|
|
1488
1680
|
}
|
|
1489
1681
|
|
|
@@ -1501,6 +1693,277 @@ bucket_definitions:
|
|
|
1501
1693
|
);
|
|
1502
1694
|
}
|
|
1503
1695
|
|
|
1696
|
+
async function setupCompactedTail() {
|
|
1697
|
+
const setup = await setupV3();
|
|
1698
|
+
const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
|
|
1699
|
+
const documents = [
|
|
1700
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1701
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1702
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1703
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1704
|
+
];
|
|
1705
|
+
await insertDocs(collection, documents);
|
|
1706
|
+
await bucketStateCollection.insertOne({
|
|
1707
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1708
|
+
last_op: 4n,
|
|
1709
|
+
next_compact_check: new Date(0),
|
|
1710
|
+
first_uncompacted_write: new Date(0),
|
|
1711
|
+
compacted_state: {
|
|
1712
|
+
op_id: 2n,
|
|
1713
|
+
checksum: documents[0].checksum + documents[1].checksum,
|
|
1714
|
+
count: documents[0].count + documents[1].count,
|
|
1715
|
+
bytes: BigInt(documents[0].size + documents[1].size),
|
|
1716
|
+
chunks: 2,
|
|
1717
|
+
at: new Date(0)
|
|
1718
|
+
},
|
|
1719
|
+
bucket_stats: {
|
|
1720
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1721
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1722
|
+
chunks: documents.length
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
return setup;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
|
|
1729
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1730
|
+
expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
|
|
1731
|
+
1n,
|
|
1732
|
+
2n,
|
|
1733
|
+
3n,
|
|
1734
|
+
4n
|
|
1735
|
+
]);
|
|
1736
|
+
|
|
1737
|
+
const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
|
|
1738
|
+
const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
|
|
1739
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1740
|
+
op_id: 4n,
|
|
1741
|
+
checksum: 70n,
|
|
1742
|
+
count: 4,
|
|
1743
|
+
bytes,
|
|
1744
|
+
chunks: documents.length
|
|
1745
|
+
});
|
|
1746
|
+
expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
|
|
1750
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1751
|
+
await insertDocs(collection, [
|
|
1752
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1753
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1754
|
+
]);
|
|
1755
|
+
await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
|
|
1756
|
+
|
|
1757
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
1758
|
+
|
|
1759
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1760
|
+
const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1761
|
+
expect(documents).toHaveLength(1);
|
|
1762
|
+
expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
|
|
1763
|
+
|
|
1764
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1765
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1766
|
+
op_id: 4n,
|
|
1767
|
+
count: 4,
|
|
1768
|
+
checksum: 70n
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
|
|
1773
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1774
|
+
const operations = [
|
|
1775
|
+
makeOp(1, 'A', 'a', ctx, sourceTableId),
|
|
1776
|
+
makeOp(2, 'B', 'b', ctx, sourceTableId),
|
|
1777
|
+
makeOp(3, 'C', 'c', ctx, sourceTableId),
|
|
1778
|
+
makeOp(4, 'D', 'd', ctx, sourceTableId)
|
|
1779
|
+
];
|
|
1780
|
+
const originalDocuments = [
|
|
1781
|
+
serializeBucketData(BUCKET, operations.slice(0, 2)),
|
|
1782
|
+
serializeBucketData(BUCKET, operations.slice(2))
|
|
1783
|
+
];
|
|
1784
|
+
const committedMerge = serializeBucketData(BUCKET, operations);
|
|
1785
|
+
const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
1786
|
+
await insertDocs(collection, [committedMerge, untouchedTail]);
|
|
1787
|
+
await bucketStateCollection.insertOne({
|
|
1788
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1789
|
+
last_op: 5n,
|
|
1790
|
+
next_compact_check: new Date(0),
|
|
1791
|
+
first_uncompacted_write: new Date(0),
|
|
1792
|
+
bucket_stats: {
|
|
1793
|
+
count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
|
|
1794
|
+
bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
|
|
1795
|
+
chunks: originalDocuments.length + 1
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
|
|
1800
|
+
|
|
1801
|
+
expect(result).toEqual({ buckets: 1 });
|
|
1802
|
+
const expectedStats = {
|
|
1803
|
+
count: committedMerge.count + untouchedTail.count,
|
|
1804
|
+
bytes: BigInt(committedMerge.size + untouchedTail.size),
|
|
1805
|
+
chunks: 2
|
|
1806
|
+
};
|
|
1807
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1808
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
1809
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1810
|
+
op_id: 4n,
|
|
1811
|
+
checksum: committedMerge.checksum,
|
|
1812
|
+
count: committedMerge.count,
|
|
1813
|
+
bytes: BigInt(committedMerge.size),
|
|
1814
|
+
chunks: 1
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test('later full compaction claims fresh state after a committed replacement', async () => {
|
|
1819
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1820
|
+
const documents = [
|
|
1821
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1822
|
+
serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
|
|
1823
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1824
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1825
|
+
];
|
|
1826
|
+
await insertDocs(collection, documents);
|
|
1827
|
+
await bucketStateCollection.insertOne({
|
|
1828
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1829
|
+
last_op: 4n,
|
|
1830
|
+
next_compact_check: new Date(0),
|
|
1831
|
+
first_uncompacted_write: new Date(0),
|
|
1832
|
+
bucket_stats: {
|
|
1833
|
+
count: documents.reduce((total, document) => total + document.count, 0),
|
|
1834
|
+
bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
|
|
1835
|
+
chunks: documents.length
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
|
|
1840
|
+
const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
|
|
1841
|
+
const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
|
|
1842
|
+
let injectedFailure = false;
|
|
1843
|
+
vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
|
|
1844
|
+
const result = await originalFlush(...args);
|
|
1845
|
+
if (!injectedFailure) {
|
|
1846
|
+
injectedFailure = true;
|
|
1847
|
+
await insertDocs(collection, [concurrentDocument]);
|
|
1848
|
+
await bucketStateCollection.updateOne(
|
|
1849
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1850
|
+
{
|
|
1851
|
+
$set: { last_op: 5n },
|
|
1852
|
+
$inc: {
|
|
1853
|
+
'bucket_stats.count': concurrentDocument.count,
|
|
1854
|
+
'bucket_stats.bytes': BigInt(concurrentDocument.size),
|
|
1855
|
+
'bucket_stats.chunks': 1
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
);
|
|
1859
|
+
throw new ObjectStorageError('failure after committed full-compaction replacement', {
|
|
1860
|
+
cause: new Error('socket reset'),
|
|
1861
|
+
retryable: true
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
return result;
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
|
|
1868
|
+
|
|
1869
|
+
expect(injectedFailure).toBe(true);
|
|
1870
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1871
|
+
expect(interruptedState?.last_op).toBe(5n);
|
|
1872
|
+
expect(interruptedState?.compacted_state).toBeUndefined();
|
|
1873
|
+
expect(interruptedState?.compact_lease).toBeUndefined();
|
|
1874
|
+
|
|
1875
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
|
|
1876
|
+
1
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
1880
|
+
expect(currentDocuments).toHaveLength(1);
|
|
1881
|
+
const expectedStats = {
|
|
1882
|
+
count: currentDocuments.reduce((total, document) => total + document.count, 0),
|
|
1883
|
+
bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
|
|
1884
|
+
chunks: currentDocuments.length
|
|
1885
|
+
};
|
|
1886
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1887
|
+
expect(state?.bucket_stats).toEqual(expectedStats);
|
|
1888
|
+
expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
|
|
1889
|
+
expect(state?.last_full_compact?.op_id).toBe(5n);
|
|
1890
|
+
expect(state?.first_uncompacted_write).toBeUndefined();
|
|
1891
|
+
expect(state?.next_compact_check).toBeUndefined();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
test('chunk compaction treats a missing cached op as a resume hint', async () => {
|
|
1895
|
+
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1896
|
+
const documents = [
|
|
1897
|
+
serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
|
|
1898
|
+
serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
|
|
1899
|
+
serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
|
|
1900
|
+
];
|
|
1901
|
+
await insertDocs(collection, documents);
|
|
1902
|
+
await bucketStateCollection.insertOne({
|
|
1903
|
+
_id: { d: ctx.definitionId, b: BUCKET },
|
|
1904
|
+
last_op: 4n,
|
|
1905
|
+
next_compact_check: new Date(0),
|
|
1906
|
+
first_uncompacted_write: new Date(0),
|
|
1907
|
+
compacted_state: {
|
|
1908
|
+
op_id: 2n,
|
|
1909
|
+
checksum: 21n,
|
|
1910
|
+
count: 2,
|
|
1911
|
+
bytes: 100n,
|
|
1912
|
+
chunks: 2,
|
|
1913
|
+
at: new Date(0)
|
|
1914
|
+
},
|
|
1915
|
+
bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
|
|
1919
|
+
1
|
|
1920
|
+
);
|
|
1921
|
+
|
|
1922
|
+
const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1923
|
+
expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
|
|
1924
|
+
const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
|
|
1925
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1926
|
+
expect(state?.compacted_state).toMatchObject({
|
|
1927
|
+
op_id: 4n,
|
|
1928
|
+
checksum: 56n,
|
|
1929
|
+
count: 3,
|
|
1930
|
+
bytes,
|
|
1931
|
+
chunks: currentDocuments.length
|
|
1932
|
+
});
|
|
1933
|
+
expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
test('later chunk compaction rebuilds state after a committed partial merge', async () => {
|
|
1937
|
+
const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
|
|
1938
|
+
const failedCompactor = bucketStorage.createMongoCompactor({
|
|
1939
|
+
maxOpId: 4n,
|
|
1940
|
+
compactChunksOnly: true
|
|
1941
|
+
});
|
|
1942
|
+
const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
|
|
1943
|
+
const flushSpy = vi
|
|
1944
|
+
.spyOn(failedCompactor as any, 'flushCompactionGroup')
|
|
1945
|
+
.mockImplementation(async (...args: any[]) => {
|
|
1946
|
+
const result = await originalFlush(...args);
|
|
1947
|
+
throw new Error('failure after committed chunk merge');
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
await expect(failedCompactor.compact()).resolves.toBe(0);
|
|
1951
|
+
flushSpy.mockRestore();
|
|
1952
|
+
|
|
1953
|
+
const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
1954
|
+
expect(interruptedState?.compacted_state?.op_id).toBe(2n);
|
|
1955
|
+
expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
|
|
1956
|
+
await bucketStateCollection.updateOne(
|
|
1957
|
+
{ _id: { d: ctx.definitionId, b: BUCKET } },
|
|
1958
|
+
{ $set: { next_compact_check: new Date(0) } }
|
|
1959
|
+
);
|
|
1960
|
+
|
|
1961
|
+
const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
|
|
1962
|
+
|
|
1963
|
+
expect(result).toBe(1);
|
|
1964
|
+
await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1504
1967
|
test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
|
|
1505
1968
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1506
1969
|
|
|
@@ -1616,6 +2079,13 @@ bucket_definitions:
|
|
|
1616
2079
|
expect(op600).toBeDefined();
|
|
1617
2080
|
expect(op600!.op).toBe('PUT');
|
|
1618
2081
|
expect(op600!.row_id).toBe('F');
|
|
2082
|
+
|
|
2083
|
+
const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
|
|
2084
|
+
expect(state?.bucket_stats).toEqual({
|
|
2085
|
+
count: docsAfter.reduce((total, document) => total + document.count, 0),
|
|
2086
|
+
bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
|
|
2087
|
+
chunks: docsAfter.length
|
|
2088
|
+
});
|
|
1619
2089
|
});
|
|
1620
2090
|
|
|
1621
2091
|
test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
|