@powersync/service-module-mongodb-storage 0.18.3 → 0.20.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 +53 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +5 -1
- package/dist/storage/MongoBucketStorage.js +5 -1
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
- package/dist/storage/implementation/MongoCompactor.js +68 -260
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +26 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +10 -4
- package/dist/storage/implementation/MongoSyncBucketStorage.js +13 -12
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
- package/dist/storage/implementation/common/PersistedBatch.js +15 -8
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +20 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +4 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +58 -84
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
- package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +19 -11
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +35 -23
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
- package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +108 -106
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
- package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
- package/dist/storage/implementation/v3/bucket-format.js +28 -9
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/chunking.js +3 -1
- package/dist/storage/implementation/v3/chunking.js.map +1 -1
- package/dist/storage/implementation/v3/models.d.ts +43 -2
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/source-table-utils.d.ts +9 -1
- package/dist/storage/implementation/v3/source-table-utils.js +20 -21
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
- package/dist/types/types.d.ts +35 -0
- package/dist/types/types.js +35 -1
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.js +3 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +8 -1
- package/dist/utils/util.js +125 -2
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -7
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +10 -2
- package/src/storage/implementation/MongoBucketBatch.ts +4 -0
- package/src/storage/implementation/MongoCompactor.ts +90 -314
- package/src/storage/implementation/MongoStorageProvider.ts +32 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +23 -22
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
- package/src/storage/implementation/common/PersistedBatch.ts +22 -10
- package/src/storage/implementation/models.ts +20 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +76 -96
- package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +53 -40
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +40 -23
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
- package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +122 -126
- package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
- package/src/storage/implementation/v3/bucket-format.ts +36 -9
- package/src/storage/implementation/v3/chunking.ts +3 -1
- package/src/storage/implementation/v3/models.ts +45 -1
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
- package/src/storage/implementation/v3/source-table-utils.ts +43 -35
- package/src/types/types.ts +42 -1
- package/src/utils/test-utils.ts +3 -1
- package/src/utils/util.ts +182 -4
- package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
- package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
- package/test/src/helpers/MemoryObjectStorage.ts +53 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/setup.ts +6 -1
- package/test/src/storage.test.ts +216 -2
- package/test/src/storage_compacting.test.ts +176 -187
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +191 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
- package/test/src/storage_s3_reading.test.ts +526 -0
- package/test/src/storage_s3_writing.test.ts +135 -0
- package/test/src/storage_sync.test.ts +124 -5
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
- package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
- package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
- package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
|
@@ -1,24 +1,100 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { logger, ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
-
import { addChecksums, storage, utils } from '@powersync/service-core';
|
|
3
|
+
import { addChecksums, InternalOpId, storage, utils } from '@powersync/service-core';
|
|
4
4
|
import { BucketDefinitionId } from '@powersync/service-sync-rules';
|
|
5
5
|
import { BucketDataDoc } from '../common/BucketDataDoc.js';
|
|
6
|
-
import { BucketDataDocumentGeneric } from '../common/SingleBucketStore.js';
|
|
7
6
|
import { BucketDataKey, BucketStateDocumentBase } from '../models.js';
|
|
8
|
-
import { DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
|
|
7
|
+
import { ConcurrentCompactionError, DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
|
|
9
8
|
import { cacheKey } from '../OperationBatch.js';
|
|
10
|
-
import { loadBucketDataDocument, serializeBucketData } from './bucket-format.js';
|
|
11
|
-
import {
|
|
9
|
+
import { loadBucketDataDocument, maxOpId, serializeBucketData } from './bucket-format.js';
|
|
10
|
+
import { BucketDataContextV3 } from './BucketDataContextV3.js';
|
|
11
|
+
import { DEFAULT_MAX_DOC_SIZE_BYTES } from './chunking.js';
|
|
12
12
|
import { BucketDataDocumentV3, BucketStateDocumentV3 } from './models.js';
|
|
13
13
|
import { DefinitionChecksumOperations, MongoChecksumsV3 } from './MongoChecksumsV3.js';
|
|
14
14
|
import type { MongoSyncBucketStorageV3 } from './MongoSyncBucketStorageV3.js';
|
|
15
|
-
import {
|
|
15
|
+
import { BucketDataObjectStorage, hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
|
|
16
|
+
import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
|
|
16
17
|
import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
|
|
17
18
|
|
|
19
|
+
interface PendingCompactionGroup {
|
|
20
|
+
/**
|
|
21
|
+
* Input documents are ordered from oldest to newest, matching `ops`.
|
|
22
|
+
* Keeping the inputs intact lets unchanged singletons retain their object.
|
|
23
|
+
*/
|
|
24
|
+
inputs: BucketDataDocumentV3[];
|
|
25
|
+
ops: BucketDataDoc[];
|
|
26
|
+
changed: boolean;
|
|
27
|
+
targetOp: InternalOpId | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read one bounded prefix from a descending compaction cursor.
|
|
32
|
+
*
|
|
33
|
+
* The document that would cross the byte limit is deliberately not returned:
|
|
34
|
+
* pagination resumes below the last returned `_id`, so that document remains
|
|
35
|
+
* eligible for the next query. The first document is always accepted to ensure
|
|
36
|
+
* progress when a single document exceeds the configured byte limit.
|
|
37
|
+
*
|
|
38
|
+
* `hasMore` is conservative when the document limit is reached. An extra empty
|
|
39
|
+
* query is preferable to exhausting the cursor just to determine whether the
|
|
40
|
+
* limited MongoDB query contained another document.
|
|
41
|
+
*/
|
|
42
|
+
async function readCompactionBatch(
|
|
43
|
+
cursor: mongo.AggregationCursor<BucketDataDocumentV3>,
|
|
44
|
+
options: { byteLimit: number; documentLimit: number }
|
|
45
|
+
): Promise<{ documents: BucketDataDocumentV3[]; hasMore: boolean }> {
|
|
46
|
+
const documents: BucketDataDocumentV3[] = [];
|
|
47
|
+
let cumulativeBytes = 0;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
for await (const document of cursor) {
|
|
51
|
+
if (documents.length > 0 && cumulativeBytes + document.size > options.byteLimit) {
|
|
52
|
+
return { documents, hasMore: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
documents.push(document);
|
|
56
|
+
cumulativeBytes += document.size;
|
|
57
|
+
|
|
58
|
+
if (documents.length >= options.documentLimit) {
|
|
59
|
+
return { documents, hasMore: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { documents, hasMore: false };
|
|
63
|
+
} finally {
|
|
64
|
+
await cursor.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
18
68
|
export class MongoCompactorV3 extends MongoCompactor {
|
|
19
69
|
declare protected readonly db: VersionedPowerSyncMongoV3;
|
|
20
70
|
declare protected readonly storage: MongoSyncBucketStorageV3;
|
|
21
71
|
|
|
72
|
+
override async compact(): Promise<void> {
|
|
73
|
+
if (this.storage.objectStorage) {
|
|
74
|
+
// Clean these before compacting - should be quick in most cases.
|
|
75
|
+
try {
|
|
76
|
+
await this.objectStorageLifecycle.cleanup(this.logger);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// In this case, still continue normal compact process
|
|
79
|
+
this.logger.error(`Failed to clean up object storage deletion markers before compaction`, e);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await super.compact();
|
|
83
|
+
if (this.storage.objectStorage) {
|
|
84
|
+
// Cleanup for any produced during compacting.
|
|
85
|
+
// Note that markers only expire after a delay, so this may skip many produced during this compact
|
|
86
|
+
// run. However, during long compact runs, this may also have many ones it can clean up.
|
|
87
|
+
await this.objectStorageLifecycle.cleanup(this.logger);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private get objectStorageLifecycle(): ObjectStorageLifecycle {
|
|
92
|
+
if (!this.storage.objectStorage) {
|
|
93
|
+
throw new Error('Object storage is not configured');
|
|
94
|
+
}
|
|
95
|
+
return new ObjectStorageLifecycle(this.db, this.group_id, this.storage.objectStorage);
|
|
96
|
+
}
|
|
97
|
+
|
|
22
98
|
public async *dirtyBucketBatches(options: {
|
|
23
99
|
minBucketChanges: number;
|
|
24
100
|
minChangeRatio: number;
|
|
@@ -97,10 +173,10 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
97
173
|
};
|
|
98
174
|
}
|
|
99
175
|
|
|
100
|
-
|
|
176
|
+
private async getBucketDataContext(
|
|
101
177
|
bucket: string,
|
|
102
178
|
definitionId: BucketDefinitionId | null
|
|
103
|
-
): Promise<
|
|
179
|
+
): Promise<BucketDataContextV3 | null> {
|
|
104
180
|
let resolvedDefinitionId = definitionId;
|
|
105
181
|
|
|
106
182
|
if (resolvedDefinitionId == null) {
|
|
@@ -120,7 +196,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
120
196
|
return null;
|
|
121
197
|
}
|
|
122
198
|
|
|
123
|
-
return new
|
|
199
|
+
return new BucketDataContextV3(this.db, {
|
|
124
200
|
bucket,
|
|
125
201
|
definitionId: resolvedDefinitionId,
|
|
126
202
|
replicationStreamId: this.group_id
|
|
@@ -138,7 +214,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
138
214
|
const context = { replicationStreamId: this.group_id, definitionId: resolvedDefinitionId };
|
|
139
215
|
|
|
140
216
|
const lowerBound = bucketContext.minId;
|
|
141
|
-
let upperBound = bucketContext.
|
|
217
|
+
let upperBound = bucketContext.docId(this.maxOpId + 1n);
|
|
142
218
|
|
|
143
219
|
let totalChecksum = 0;
|
|
144
220
|
let totalOpCount = 0;
|
|
@@ -146,10 +222,11 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
146
222
|
|
|
147
223
|
let lastNotPut: bigint | null = null;
|
|
148
224
|
let opsSincePut = 0;
|
|
149
|
-
let
|
|
150
|
-
|
|
225
|
+
let compactedOpId: bigint | null = null;
|
|
226
|
+
let clearBoundary: { opId: bigint; documentId: BucketDataKey } | null = null;
|
|
151
227
|
const seen = new Map<string, bigint>();
|
|
152
228
|
let trackingSize = 0;
|
|
229
|
+
let pendingGroup: PendingCompactionGroup | null = null;
|
|
153
230
|
|
|
154
231
|
// --- Read batch from MongoDB ---
|
|
155
232
|
while (true) {
|
|
@@ -158,7 +235,6 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
158
235
|
const pipeline: mongo.Document[] = [
|
|
159
236
|
{
|
|
160
237
|
$match: {
|
|
161
|
-
'_id.b': bucket,
|
|
162
238
|
_id: {
|
|
163
239
|
$gte: lowerBound,
|
|
164
240
|
$lt: upperBound
|
|
@@ -176,258 +252,182 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
176
252
|
size: 1,
|
|
177
253
|
target_op: 1,
|
|
178
254
|
ops: 1,
|
|
179
|
-
|
|
255
|
+
storage_ref: 1
|
|
180
256
|
}
|
|
181
257
|
}
|
|
182
258
|
];
|
|
183
259
|
|
|
184
|
-
const
|
|
185
|
-
.aggregate<BucketDataDocumentV3
|
|
260
|
+
const batch = await readCompactionBatch(
|
|
261
|
+
collection.aggregate<BucketDataDocumentV3>(pipeline, {
|
|
186
262
|
batchSize: this.moveBatchQueryLimit + 1
|
|
187
|
-
})
|
|
188
|
-
|
|
263
|
+
}),
|
|
264
|
+
{
|
|
265
|
+
byteLimit: this.moveBatchByteLimit,
|
|
266
|
+
documentLimit: this.moveBatchQueryLimit
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
const batchDocs = batch.documents;
|
|
189
270
|
|
|
190
|
-
if (
|
|
271
|
+
if (batchDocs.length == 0) {
|
|
191
272
|
// No more documents in this bucket — compaction complete.
|
|
192
273
|
break;
|
|
193
274
|
}
|
|
194
275
|
|
|
195
|
-
|
|
196
|
-
let cumulativeBytes = 0;
|
|
197
|
-
let batchCutIndex = rawBatch.length;
|
|
198
|
-
|
|
199
|
-
for (let i = 0; i < rawBatch.length; i++) {
|
|
200
|
-
cumulativeBytes += Number(rawBatch[i].bsonSize);
|
|
201
|
-
if (cumulativeBytes > this.moveBatchByteLimit && i > 0) {
|
|
202
|
-
// Byte limit exceeded; cut batch at current index. Always include
|
|
203
|
-
// at least one document (i > 0 guard) to guarantee forward progress.
|
|
204
|
-
batchCutIndex = i;
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const batchDocs = rawBatch.slice(0, batchCutIndex);
|
|
210
|
-
|
|
211
|
-
// --- Decode documents into individual ops ---
|
|
212
|
-
// Processable: document has at least one op <= maxOpId.
|
|
213
|
-
// Only processable docs are deleted and recreated; the rest survive untouched.
|
|
214
|
-
const batchOps: BucketDataDoc[] = [];
|
|
215
|
-
const processableDocs: (BucketDataDocumentV3 & { bsonSize: number | bigint })[] = [];
|
|
276
|
+
await hydrateBucketDataDocuments(batchDocs, this.storage.objectStorage, { signal: this.signal });
|
|
216
277
|
|
|
278
|
+
// Compact each document independently, then greedily merge adjacent
|
|
279
|
+
// post-compaction results. This preserves existing boundaries unless
|
|
280
|
+
// merging is useful, and writes each final object at most once.
|
|
217
281
|
for (const doc of batchDocs) {
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (const op of batchOps) {
|
|
261
|
-
if (op.op == 'PUT' || op.op == 'REMOVE') {
|
|
262
|
-
if (op.o > this.maxOpId) {
|
|
263
|
-
surviving.push(op);
|
|
264
|
-
continue; // Do not dedup ops above compaction horizon
|
|
265
|
-
}
|
|
266
|
-
const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
|
|
267
|
-
const targetOp = seen.get(key);
|
|
268
|
-
if (targetOp != null) {
|
|
269
|
-
surviving.push({
|
|
270
|
-
...op,
|
|
271
|
-
op: 'MOVE',
|
|
272
|
-
target_op: targetOp,
|
|
273
|
-
table: undefined,
|
|
274
|
-
row_id: undefined,
|
|
275
|
-
source_table: undefined,
|
|
276
|
-
source_key: undefined,
|
|
277
|
-
data: null
|
|
278
|
-
});
|
|
279
|
-
if (lastNotPut == null) {
|
|
280
|
-
lastNotPut = op.o;
|
|
282
|
+
compactedOpId ??= doc._id.o;
|
|
283
|
+
const originalOps = Array.from(loadBucketDataDocument(context, doc));
|
|
284
|
+
|
|
285
|
+
let changed = false;
|
|
286
|
+
const compactedOps: BucketDataDoc[] = [];
|
|
287
|
+
let maxTargetOp: InternalOpId | null = doc.target_op ?? null;
|
|
288
|
+
for (let index = originalOps.length - 1; index >= 0; index--) {
|
|
289
|
+
const op = originalOps[index];
|
|
290
|
+
if (op.op == 'PUT' || op.op == 'REMOVE') {
|
|
291
|
+
const key = `${op.table}/${op.row_id}/${cacheKey(op.source_table!, op.source_key!)}`;
|
|
292
|
+
const targetOp = seen.get(key);
|
|
293
|
+
if (targetOp != null) {
|
|
294
|
+
maxTargetOp = maxOpId(maxTargetOp, targetOp);
|
|
295
|
+
compactedOps.push({
|
|
296
|
+
...op,
|
|
297
|
+
op: 'MOVE',
|
|
298
|
+
table: undefined,
|
|
299
|
+
row_id: undefined,
|
|
300
|
+
source_table: undefined,
|
|
301
|
+
source_key: undefined,
|
|
302
|
+
data: null
|
|
303
|
+
});
|
|
304
|
+
changed = true;
|
|
305
|
+
if (lastNotPut == null) {
|
|
306
|
+
lastNotPut = op.o;
|
|
307
|
+
}
|
|
308
|
+
opsSincePut += 1;
|
|
309
|
+
} else {
|
|
310
|
+
if (trackingSize < this.idLimitBytes) {
|
|
311
|
+
seen.set(utils.flatstr(key), op.o);
|
|
312
|
+
trackingSize += key.length + 140;
|
|
313
|
+
}
|
|
314
|
+
compactedOps.push(op);
|
|
315
|
+
if (op.op == 'PUT') {
|
|
316
|
+
lastNotPut = null;
|
|
317
|
+
opsSincePut = 0;
|
|
318
|
+
} else {
|
|
319
|
+
if (lastNotPut == null) {
|
|
320
|
+
lastNotPut = op.o;
|
|
321
|
+
}
|
|
322
|
+
opsSincePut += 1;
|
|
323
|
+
}
|
|
281
324
|
}
|
|
282
|
-
opsSincePut += 1;
|
|
283
325
|
} else {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
trackingSize += key.length + 140;
|
|
287
|
-
}
|
|
288
|
-
surviving.push(op);
|
|
289
|
-
if (op.op == 'PUT') {
|
|
290
|
-
lastNotPut = null;
|
|
291
|
-
opsSincePut = 0;
|
|
292
|
-
} else {
|
|
326
|
+
compactedOps.push(op);
|
|
327
|
+
if (op.op != 'CLEAR') {
|
|
293
328
|
if (lastNotPut == null) {
|
|
294
329
|
lastNotPut = op.o;
|
|
295
330
|
}
|
|
296
331
|
opsSincePut += 1;
|
|
297
332
|
}
|
|
298
333
|
}
|
|
299
|
-
} else {
|
|
300
|
-
surviving.push(op);
|
|
301
|
-
if (op.op != 'CLEAR') {
|
|
302
|
-
if (lastNotPut == null) {
|
|
303
|
-
lastNotPut = op.o;
|
|
304
|
-
}
|
|
305
|
-
opsSincePut += 1;
|
|
306
|
-
}
|
|
307
334
|
}
|
|
308
|
-
|
|
335
|
+
compactedOps.reverse();
|
|
309
336
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// --- Rechunk survivors into new V3 documents ---
|
|
314
|
-
const chunks = chunkBucketData(surviving);
|
|
315
|
-
const newDocs = chunks.map((chunk) => serializeBucketData(bucket, chunk));
|
|
316
|
-
|
|
317
|
-
if (lastNotPut == null) {
|
|
318
|
-
clearBoundaryDocId = null;
|
|
319
|
-
} else {
|
|
320
|
-
const boundaryOp = lastNotPut;
|
|
321
|
-
const boundaryDoc = newDocs.find((doc) => doc.min_op <= boundaryOp && doc._id.o >= boundaryOp);
|
|
322
|
-
if (boundaryDoc != null) {
|
|
323
|
-
clearBoundaryDocId = boundaryDoc._id;
|
|
337
|
+
for (const op of compactedOps) {
|
|
338
|
+
totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
|
|
339
|
+
totalOpBytes += op.data?.length ?? 0;
|
|
324
340
|
}
|
|
325
|
-
|
|
341
|
+
totalOpCount += compactedOps.length;
|
|
326
342
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// This aggregate anchors the transaction snapshot and catches
|
|
334
|
-
// concurrent compaction jobs that modified the same documents.
|
|
335
|
-
const verification = await bucketContext.collection
|
|
336
|
-
.aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
|
|
337
|
-
[
|
|
338
|
-
{ $match: { _id: { $in: idsToDelete } } },
|
|
339
|
-
{
|
|
340
|
-
$group: {
|
|
341
|
-
_id: null,
|
|
342
|
-
docCount: { $sum: 1 },
|
|
343
|
-
checksumSum: { $sum: '$checksum' },
|
|
344
|
-
opCountSum: { $sum: '$count' }
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
],
|
|
348
|
-
{ session }
|
|
349
|
-
)
|
|
350
|
-
.next();
|
|
343
|
+
const candidate: PendingCompactionGroup = {
|
|
344
|
+
inputs: [doc],
|
|
345
|
+
ops: compactedOps,
|
|
346
|
+
changed,
|
|
347
|
+
targetOp: maxTargetOp
|
|
348
|
+
};
|
|
351
349
|
|
|
350
|
+
if (pendingGroup == null) {
|
|
351
|
+
pendingGroup = candidate;
|
|
352
|
+
} else {
|
|
353
|
+
const mergedOps: BucketDataDoc[] = [...candidate.ops, ...pendingGroup.ops];
|
|
354
|
+
const mergedSize = serializeBucketData(bucket, mergedOps, { targetOp: maxTargetOp }).size;
|
|
355
|
+
if (mergedSize <= DEFAULT_MAX_DOC_SIZE_BYTES) {
|
|
356
|
+
pendingGroup = {
|
|
357
|
+
inputs: [...candidate.inputs, ...pendingGroup.inputs],
|
|
358
|
+
ops: mergedOps,
|
|
359
|
+
changed: candidate.changed || pendingGroup.changed,
|
|
360
|
+
targetOp: maxOpId(maxTargetOp, pendingGroup.targetOp)
|
|
361
|
+
};
|
|
362
|
+
} else {
|
|
363
|
+
const flushedGroup = pendingGroup;
|
|
364
|
+
const documentId = await this.flushCompactionGroup(bucket, flushedGroup, bucketContext, context);
|
|
352
365
|
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
verification.opCountSum !== expectedOpCount // ops added/removed within docs
|
|
366
|
+
lastNotPut != null &&
|
|
367
|
+
flushedGroup.ops[0].o <= lastNotPut &&
|
|
368
|
+
flushedGroup.ops[flushedGroup.ops.length - 1].o >= lastNotPut
|
|
357
369
|
) {
|
|
358
|
-
|
|
359
|
-
`Concurrent modification detected in bucket ${bucket}. Aborting compaction for this batch.`
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
await bucketContext.collection.deleteMany(
|
|
364
|
-
{
|
|
365
|
-
_id: { $in: idsToDelete }
|
|
366
|
-
} as any,
|
|
367
|
-
{ session }
|
|
368
|
-
);
|
|
369
|
-
if (newDocs.length > 0) {
|
|
370
|
-
await bucketContext.collection.insertMany(newDocs as unknown as BucketDataDocumentGeneric[], { session });
|
|
370
|
+
clearBoundary = { opId: lastNotPut, documentId };
|
|
371
371
|
}
|
|
372
|
-
|
|
373
|
-
{
|
|
374
|
-
writeConcern: { w: 'majority' },
|
|
375
|
-
readConcern: { level: 'snapshot' }
|
|
376
|
-
}
|
|
377
|
-
);
|
|
378
|
-
} finally {
|
|
379
|
-
await session.endSession();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// --- Accumulate bucket state ---
|
|
383
|
-
for (const chunk of chunks) {
|
|
384
|
-
for (const op of chunk) {
|
|
385
|
-
if (op.o <= this.maxOpId) {
|
|
386
|
-
totalChecksum = addChecksums(totalChecksum, Number(op.checksum));
|
|
387
|
-
totalOpBytes += op.data?.length ?? 0;
|
|
372
|
+
pendingGroup = candidate;
|
|
388
373
|
}
|
|
389
374
|
}
|
|
390
375
|
}
|
|
391
|
-
totalOpCount += surviving.filter((op) => op.o <= this.maxOpId).length;
|
|
392
376
|
|
|
393
377
|
// --- Advance to next batch ---
|
|
394
|
-
upperBound =
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
// The upperBound is already set to the last doc we processed
|
|
399
|
-
} else {
|
|
400
|
-
// Processed all docs in the raw batch. If we got fewer than the query
|
|
401
|
-
// limit, there are no more documents in this bucket — compaction complete.
|
|
402
|
-
if (rawBatch.length < this.moveBatchQueryLimit) {
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
378
|
+
upperBound = batchDocs[batchDocs.length - 1]._id as typeof upperBound;
|
|
379
|
+
|
|
380
|
+
if (!batch.hasMore) {
|
|
381
|
+
break;
|
|
405
382
|
}
|
|
406
383
|
|
|
407
384
|
this.logger.info(`Compacted batch of ${batchDocs.length} documents for bucket ${bucket}`);
|
|
408
385
|
}
|
|
409
386
|
|
|
387
|
+
if (pendingGroup != null) {
|
|
388
|
+
const documentId = await this.flushCompactionGroup(bucket, pendingGroup, bucketContext, context);
|
|
389
|
+
if (
|
|
390
|
+
lastNotPut != null &&
|
|
391
|
+
pendingGroup.ops[0].o <= lastNotPut &&
|
|
392
|
+
pendingGroup.ops[pendingGroup.ops.length - 1].o >= lastNotPut
|
|
393
|
+
) {
|
|
394
|
+
clearBoundary = { opId: lastNotPut, documentId };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (compactedOpId == null) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
410
401
|
// --- Clear: collapse leading MOVE/REMOVE/CLEAR sequence ---
|
|
411
402
|
if (lastNotPut != null && opsSincePut >= 2) {
|
|
412
|
-
if (
|
|
403
|
+
if (clearBoundary == null || clearBoundary.opId != lastNotPut) {
|
|
413
404
|
throw new ReplicationAssertionError(`Missing CLEAR boundary document for bucket ${bucket}`);
|
|
414
405
|
}
|
|
415
406
|
|
|
416
|
-
totalOpCount += await this.clearBucketLeading(
|
|
407
|
+
totalOpCount += await this.clearBucketLeading(
|
|
408
|
+
lastNotPut,
|
|
409
|
+
clearBoundary.documentId,
|
|
410
|
+
bucketContext,
|
|
411
|
+
collection,
|
|
412
|
+
context
|
|
413
|
+
);
|
|
417
414
|
}
|
|
418
415
|
|
|
419
416
|
// --- Finalize: update bucket checksums and state ---
|
|
420
|
-
this.updateBucketChecksums(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
417
|
+
this.updateBucketChecksums(
|
|
418
|
+
{
|
|
419
|
+
bucket,
|
|
420
|
+
definitionId: resolvedDefinitionId,
|
|
421
|
+
seen: new Map(),
|
|
422
|
+
trackingSize: 0,
|
|
423
|
+
lastNotPut: lastNotPut,
|
|
424
|
+
opsSincePut: opsSincePut,
|
|
425
|
+
checksum: totalChecksum,
|
|
426
|
+
opCount: totalOpCount,
|
|
427
|
+
opBytes: totalOpBytes
|
|
428
|
+
},
|
|
429
|
+
compactedOpId
|
|
430
|
+
);
|
|
431
431
|
if (this.bucketStateUpdates.length > 0) {
|
|
432
432
|
await this.writeBucketStateUpdates();
|
|
433
433
|
this.bucketStateUpdates = [];
|
|
@@ -436,6 +436,80 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
436
436
|
logger.info(`Compacted bucket ${bucket}: ${totalOpCount} surviving ops`);
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Persist replacement objects before starting the transaction, then atomically
|
|
441
|
+
* publish their lifecycle markers alongside the MongoDB document replacement.
|
|
442
|
+
* If verification or the transaction fails, the prepared markers retain enough
|
|
443
|
+
* information for the uploaded objects to be cleaned up later.
|
|
444
|
+
*/
|
|
445
|
+
private async flushCompactionGroup(
|
|
446
|
+
bucket: string,
|
|
447
|
+
group: PendingCompactionGroup,
|
|
448
|
+
bucketContext: BucketDataContextV3,
|
|
449
|
+
context: { replicationStreamId: number; definitionId: string }
|
|
450
|
+
): Promise<BucketDataKey> {
|
|
451
|
+
if (group.inputs.length == 1 && !group.changed) {
|
|
452
|
+
return group.inputs[0]._id;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const inputs = group.inputs;
|
|
456
|
+
const idsToDelete = inputs.map((doc) => doc._id);
|
|
457
|
+
const expectedDocCount = inputs.length;
|
|
458
|
+
const expectedChecksum = inputs.reduce((sum, doc) => sum + doc.checksum, 0n);
|
|
459
|
+
const expectedOpCount = inputs.reduce((sum, doc) => sum + doc.count, 0);
|
|
460
|
+
const oldStoragePaths = inputs.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
|
|
461
|
+
const {
|
|
462
|
+
documents,
|
|
463
|
+
storagePaths: newStoragePaths,
|
|
464
|
+
uploads
|
|
465
|
+
} = await this.persistBucketData(bucket, [group.ops], context, undefined, { targetOp: group.targetOp });
|
|
466
|
+
const session = this.db.client.startSession();
|
|
467
|
+
try {
|
|
468
|
+
await session.withTransaction(
|
|
469
|
+
async () => {
|
|
470
|
+
const verification = await bucketContext.collection
|
|
471
|
+
.aggregate<{ docCount: number; checksumSum: bigint | null; opCountSum: number | null }>(
|
|
472
|
+
[
|
|
473
|
+
{ $match: { _id: { $in: idsToDelete } } },
|
|
474
|
+
{
|
|
475
|
+
$group: {
|
|
476
|
+
_id: null,
|
|
477
|
+
docCount: { $sum: 1 },
|
|
478
|
+
checksumSum: { $sum: '$checksum' },
|
|
479
|
+
opCountSum: { $sum: '$count' }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
],
|
|
483
|
+
{ session }
|
|
484
|
+
)
|
|
485
|
+
.next();
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
verification == null ||
|
|
489
|
+
verification.docCount !== expectedDocCount ||
|
|
490
|
+
verification.checksumSum !== expectedChecksum ||
|
|
491
|
+
verification.opCountSum !== expectedOpCount
|
|
492
|
+
) {
|
|
493
|
+
throw new ConcurrentCompactionError(
|
|
494
|
+
`Inputs changed while compacting bucket ${bucket}; restarting from the latest bucket state`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
await bucketContext.collection.deleteMany({ _id: { $in: idsToDelete } }, { session });
|
|
499
|
+
await bucketContext.collection.insertMany(documents, { session });
|
|
500
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, newStoragePaths, uploads, session);
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
writeConcern: { w: 'majority' },
|
|
504
|
+
readConcern: { level: 'snapshot' }
|
|
505
|
+
}
|
|
506
|
+
);
|
|
507
|
+
} finally {
|
|
508
|
+
await session.endSession();
|
|
509
|
+
}
|
|
510
|
+
return documents[0]._id;
|
|
511
|
+
}
|
|
512
|
+
|
|
439
513
|
/**
|
|
440
514
|
* Collapse the leading sequence of MOVE/REMOVE/CLEAR ops at the start
|
|
441
515
|
* of the bucket into a single CLEAR op. Reads whole clearable documents
|
|
@@ -447,7 +521,7 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
447
521
|
private async clearBucketLeading(
|
|
448
522
|
lastNotPut: bigint,
|
|
449
523
|
boundaryDocId: BucketDataKey,
|
|
450
|
-
bucketContext:
|
|
524
|
+
bucketContext: BucketDataContextV3,
|
|
451
525
|
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
452
526
|
context: { replicationStreamId: number; definitionId: string }
|
|
453
527
|
): Promise<number> {
|
|
@@ -491,17 +565,21 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
491
565
|
session: mongo.ClientSession,
|
|
492
566
|
lastNotPut: bigint,
|
|
493
567
|
boundaryDocId: BucketDataKey,
|
|
494
|
-
bucketContext:
|
|
568
|
+
bucketContext: BucketDataContextV3,
|
|
495
569
|
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
496
570
|
context: { replicationStreamId: number; definitionId: string }
|
|
497
571
|
): Promise<{ done: boolean; opCountDiff: number }> {
|
|
498
572
|
const bucket = bucketContext.key.bucket;
|
|
573
|
+
this.signal?.throwIfAborted();
|
|
574
|
+
const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut]);
|
|
499
575
|
let done = false;
|
|
500
576
|
let opCountDiff = 0;
|
|
501
577
|
|
|
502
|
-
this.signal?.throwIfAborted();
|
|
503
578
|
await session.withTransaction(
|
|
504
579
|
async () => {
|
|
580
|
+
done = false;
|
|
581
|
+
opCountDiff = 0;
|
|
582
|
+
const oldStoragePaths: string[] = [];
|
|
505
583
|
const query = collection.find(
|
|
506
584
|
{
|
|
507
585
|
_id: {
|
|
@@ -518,7 +596,8 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
518
596
|
checksum: 1,
|
|
519
597
|
count: 1,
|
|
520
598
|
target_op: 1,
|
|
521
|
-
|
|
599
|
+
has_clear_op: 1,
|
|
600
|
+
storage_ref: 1
|
|
522
601
|
},
|
|
523
602
|
limit: this.clearBatchLimit
|
|
524
603
|
}
|
|
@@ -539,30 +618,27 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
539
618
|
}
|
|
540
619
|
|
|
541
620
|
lastDocId = doc._id;
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
`Unexpected op ${op.o} after CLEAR boundary ${lastNotPut} in bucket ${bucket}`
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
if (op.op == 'PUT') {
|
|
549
|
-
throw new ReplicationAssertionError(`Unexpected PUT at op ${op.o} in CLEAR region for bucket ${bucket}`);
|
|
550
|
-
}
|
|
621
|
+
if (doc.storage_ref) {
|
|
622
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
623
|
+
}
|
|
551
624
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
|
|
561
|
-
clearedOpCount++;
|
|
562
|
-
if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
|
|
563
|
-
maxTargetOp = op.target_op;
|
|
625
|
+
// The compaction scan established that every operation before the
|
|
626
|
+
// boundary is MOVE/REMOVE/CLEAR. Root metadata is sufficient to fold
|
|
627
|
+
// whole documents into one CLEAR, so avoid downloading their payloads.
|
|
628
|
+
if (doc.has_clear_op) {
|
|
629
|
+
clearOpCount++;
|
|
630
|
+
if (clearOpCount > 1) {
|
|
631
|
+
throw new ReplicationAssertionError(`Unexpected multiple CLEAR operations in bucket ${bucket}`);
|
|
564
632
|
}
|
|
565
633
|
}
|
|
634
|
+
if (!doc.has_clear_op || doc.count > 1) {
|
|
635
|
+
gotNonClearOp = true;
|
|
636
|
+
}
|
|
637
|
+
combinedChecksum = addChecksums(combinedChecksum, Number(doc.checksum));
|
|
638
|
+
clearedOpCount += doc.count;
|
|
639
|
+
if (doc.target_op != null && (maxTargetOp == null || doc.target_op > maxTargetOp)) {
|
|
640
|
+
maxTargetOp = doc.target_op;
|
|
641
|
+
}
|
|
566
642
|
}
|
|
567
643
|
|
|
568
644
|
if (!gotNonClearOp) {
|
|
@@ -586,10 +662,13 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
586
662
|
o: lastDocId!.o,
|
|
587
663
|
op: 'CLEAR' as const,
|
|
588
664
|
checksum: BigInt(combinedChecksum),
|
|
589
|
-
data: null
|
|
590
|
-
target_op: maxTargetOp
|
|
665
|
+
data: null
|
|
591
666
|
} satisfies BucketDataDoc;
|
|
592
|
-
await
|
|
667
|
+
const persisted = await this.persistBucketData(bucket, [[clearOp]], context, prepared, {
|
|
668
|
+
targetOp: maxTargetOp
|
|
669
|
+
});
|
|
670
|
+
await collection.insertOne(persisted.documents[0], { session });
|
|
671
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
|
|
593
672
|
|
|
594
673
|
opCountDiff = -clearedOpCount + 1;
|
|
595
674
|
},
|
|
@@ -606,15 +685,19 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
606
685
|
session: mongo.ClientSession,
|
|
607
686
|
lastNotPut: bigint,
|
|
608
687
|
boundaryDocId: BucketDataKey,
|
|
609
|
-
bucketContext:
|
|
688
|
+
bucketContext: BucketDataContextV3,
|
|
610
689
|
collection: mongo.Collection<BucketDataDocumentV3 & { bsonSize?: number | bigint }>,
|
|
611
690
|
context: { replicationStreamId: number; definitionId: string }
|
|
612
691
|
): Promise<number> {
|
|
613
692
|
const bucket = bucketContext.key.bucket;
|
|
693
|
+
this.signal?.throwIfAborted();
|
|
694
|
+
const prepared = await this.prepareCompactionUploads(bucket, context, [lastNotPut, boundaryDocId.o]);
|
|
614
695
|
let opCountDiff = 0;
|
|
615
696
|
|
|
616
697
|
await session.withTransaction(
|
|
617
698
|
async () => {
|
|
699
|
+
opCountDiff = 0;
|
|
700
|
+
const oldStoragePaths: string[] = [];
|
|
618
701
|
const query = collection.find(
|
|
619
702
|
{
|
|
620
703
|
// This is a range query, but should only ever return two documents:
|
|
@@ -634,7 +717,8 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
634
717
|
checksum: 1,
|
|
635
718
|
count: 1,
|
|
636
719
|
target_op: 1,
|
|
637
|
-
ops: 1
|
|
720
|
+
ops: 1,
|
|
721
|
+
storage_ref: 1
|
|
638
722
|
},
|
|
639
723
|
limit: 3
|
|
640
724
|
}
|
|
@@ -653,6 +737,11 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
653
737
|
}
|
|
654
738
|
|
|
655
739
|
const isBoundaryDoc = doc._id.o == boundaryDocId.o;
|
|
740
|
+
if (doc.storage_ref) {
|
|
741
|
+
oldStoragePaths.push(doc.storage_ref.path);
|
|
742
|
+
}
|
|
743
|
+
await hydrateBucketDataDocuments([doc], this.storage.objectStorage, { signal: this.signal });
|
|
744
|
+
maxTargetOp = maxOpId(maxTargetOp, doc.target_op);
|
|
656
745
|
for (const op of loadBucketDataDocument(context, doc)) {
|
|
657
746
|
if (!isBoundaryDoc && op.op != 'CLEAR') {
|
|
658
747
|
throw new ReplicationAssertionError(
|
|
@@ -668,9 +757,6 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
668
757
|
}
|
|
669
758
|
combinedChecksum = addChecksums(combinedChecksum, Number(op.checksum));
|
|
670
759
|
clearedOpCount++;
|
|
671
|
-
if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
|
|
672
|
-
maxTargetOp = op.target_op;
|
|
673
|
-
}
|
|
674
760
|
} else if (isBoundaryDoc) {
|
|
675
761
|
boundarySurvivors.push(op);
|
|
676
762
|
} else {
|
|
@@ -701,15 +787,19 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
701
787
|
o: lastNotPut,
|
|
702
788
|
op: 'CLEAR' as const,
|
|
703
789
|
checksum: BigInt(combinedChecksum),
|
|
704
|
-
data: null
|
|
705
|
-
target_op: maxTargetOp
|
|
790
|
+
data: null
|
|
706
791
|
} satisfies BucketDataDoc;
|
|
707
|
-
|
|
708
|
-
|
|
792
|
+
const chunks: BucketDataDoc[][] = [[clearOp]];
|
|
709
793
|
if (boundarySurvivors.length > 0) {
|
|
710
|
-
|
|
711
|
-
|
|
794
|
+
// These operations are a subset of one existing document, so keeping
|
|
795
|
+
// them together cannot increase its stored ops payload.
|
|
796
|
+
chunks.push(boundarySurvivors);
|
|
712
797
|
}
|
|
798
|
+
const persisted = await this.persistBucketData(bucket, chunks, context, prepared, {
|
|
799
|
+
targetOp: maxTargetOp ?? undefined
|
|
800
|
+
});
|
|
801
|
+
await collection.insertMany(persisted.documents, { session });
|
|
802
|
+
await this.finishObjectStorageReplacement(oldStoragePaths, persisted.storagePaths, persisted.uploads, session);
|
|
713
803
|
|
|
714
804
|
opCountDiff = -clearedOpCount + 1;
|
|
715
805
|
},
|
|
@@ -721,4 +811,112 @@ export class MongoCompactorV3 extends MongoCompactor {
|
|
|
721
811
|
|
|
722
812
|
return opCountDiff;
|
|
723
813
|
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Reserve stable object paths before starting a retryable MongoDB transaction.
|
|
817
|
+
* Each retry can safely overwrite the same paths, while the pre-existing
|
|
818
|
+
* deletion markers remain visible to the transaction that publishes them.
|
|
819
|
+
*
|
|
820
|
+
* CLEAR compaction reserves its maximum output count. Unused markers remain
|
|
821
|
+
* pending so they can clean up a path that an earlier transaction attempt may
|
|
822
|
+
* have uploaded before retrying with fewer output documents.
|
|
823
|
+
*/
|
|
824
|
+
private async prepareCompactionUploads(
|
|
825
|
+
bucket: string,
|
|
826
|
+
context: { replicationStreamId: number; definitionId: string },
|
|
827
|
+
opIdHints: bigint[]
|
|
828
|
+
): Promise<PreparedObjectStorageUpload[]> {
|
|
829
|
+
if (!this.storage.objectStorage) {
|
|
830
|
+
return [];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
834
|
+
const paths = opIdHints.map((opIdHint) => lifecycle.allocatePath(context.definitionId, bucket, opIdHint, opIdHint));
|
|
835
|
+
return lifecycle.prepareUploads(paths);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Publish replacement uploads and retire superseded objects in the same transaction. */
|
|
839
|
+
private async finishObjectStorageReplacement(
|
|
840
|
+
oldStoragePaths: Iterable<string>,
|
|
841
|
+
newStoragePaths: Set<string>,
|
|
842
|
+
uploads: PreparedObjectStorageUpload[],
|
|
843
|
+
session: mongo.ClientSession
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
if (!this.storage.objectStorage) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await this.objectStorageLifecycle.publishUploads(uploads, session);
|
|
849
|
+
await this.objectStorageLifecycle.retire(
|
|
850
|
+
Array.from(oldStoragePaths).filter((path) => !newStoragePaths.has(path)),
|
|
851
|
+
session
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async persistBucketData(
|
|
856
|
+
bucket: string,
|
|
857
|
+
chunks: BucketDataDoc[][],
|
|
858
|
+
context: { replicationStreamId: number; definitionId: string },
|
|
859
|
+
preparedUploads?: PreparedObjectStorageUpload[],
|
|
860
|
+
options?: { targetOp?: InternalOpId | null }
|
|
861
|
+
): Promise<{ documents: BucketDataDocumentV3[]; storagePaths: Set<string>; uploads: PreparedObjectStorageUpload[] }> {
|
|
862
|
+
const serializedChunks = chunks.map((chunk) => serializeBucketData(bucket, chunk, options));
|
|
863
|
+
if (!this.storage.objectStorage) {
|
|
864
|
+
return {
|
|
865
|
+
documents: serializedChunks,
|
|
866
|
+
storagePaths: new Set(),
|
|
867
|
+
uploads: []
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const store = new BucketDataObjectStorage(this.storage.objectStorage);
|
|
872
|
+
const storagePaths = new Set<string>();
|
|
873
|
+
const lifecycle = this.objectStorageLifecycle;
|
|
874
|
+
// Base placement on the final compacted size. Unchanged documents are not
|
|
875
|
+
// rewritten, while small MOVE/merge results and CLEAR ops stay inline.
|
|
876
|
+
const storedIndexes = serializedChunks.flatMap((document, index) =>
|
|
877
|
+
document.size > this.storage.inlineThresholdBytes ? [index] : []
|
|
878
|
+
);
|
|
879
|
+
const uploadsByIndex = new Map<number, PreparedObjectStorageUpload>();
|
|
880
|
+
|
|
881
|
+
if (preparedUploads) {
|
|
882
|
+
for (const index of storedIndexes) {
|
|
883
|
+
const upload = preparedUploads[index];
|
|
884
|
+
if (!upload) {
|
|
885
|
+
throw new ServiceAssertionError(
|
|
886
|
+
`Missing prepared object storage path for compacted document at index ${index}`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
uploadsByIndex.set(index, upload);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
const paths = storedIndexes.map((index) => {
|
|
893
|
+
const chunk = chunks[index];
|
|
894
|
+
return lifecycle.allocatePath(context.definitionId, bucket, chunk[0].o, chunk[chunk.length - 1].o);
|
|
895
|
+
});
|
|
896
|
+
const prepared = await lifecycle.prepareUploads(paths);
|
|
897
|
+
storedIndexes.forEach((index, preparedIndex) => uploadsByIndex.set(index, prepared[preparedIndex]));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// S3ObjectStorage applies one shared concurrency limit across all callers,
|
|
901
|
+
// so compaction can schedule its uploads together without creating a
|
|
902
|
+
// separate limiter here.
|
|
903
|
+
const documents = await Promise.all(
|
|
904
|
+
serializedChunks.map(async (serialized, index) => {
|
|
905
|
+
const upload = uploadsByIndex.get(index);
|
|
906
|
+
if (!upload) {
|
|
907
|
+
return serialized;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const { ops, ...metadata } = serialized;
|
|
911
|
+
const { fileSize } = await store.store(upload.path, ops!);
|
|
912
|
+
storagePaths.add(upload.path);
|
|
913
|
+
return {
|
|
914
|
+
...metadata,
|
|
915
|
+
storage_ref: { path: upload.path, file_size: fileSize }
|
|
916
|
+
};
|
|
917
|
+
})
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
return { documents, storagePaths, uploads: Array.from(uploadsByIndex.values()) };
|
|
921
|
+
}
|
|
724
922
|
}
|