@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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.
Files changed (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -1,3 +1,5 @@
1
+ import * as timers from 'node:timers/promises';
2
+
1
3
  import { isMongoServerError, mongo, MONGO_OPERATION_TIMEOUT_MS } from '@powersync/lib-service-mongodb';
2
4
  import {
3
5
  logger as defaultLogger,
@@ -5,24 +7,16 @@ import {
5
7
  ReplicationAssertionError,
6
8
  ServiceAssertionError
7
9
  } from '@powersync/lib-services-framework';
8
- import {
9
- addChecksums,
10
- InternalOpId,
11
- isPartialChecksum,
12
- PopulateChecksumCacheResults,
13
- storage,
14
- utils
15
- } from '@powersync/service-core';
10
+ import { InternalOpId, isPartialChecksum, PopulateChecksumCacheResults, storage } from '@powersync/service-core';
16
11
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
17
12
 
18
- import { BucketDataDoc, BucketKey } from './common/BucketDataDoc.js';
19
- import { BucketDataDocumentGeneric, SingleBucketStore } from './common/SingleBucketStore.js';
13
+ import { BucketKey } from './common/BucketDataDoc.js';
20
14
  import type { VersionedPowerSyncMongo } from './db.js';
21
15
  import { BucketStateDocumentBase } from './models.js';
22
16
  import type { MongoSyncBucketStorage } from './MongoSyncBucketStorage.js';
23
- import { cacheKey } from './OperationBatch.js';
17
+ import { isRetryableObjectStorageError } from './v3/object-storage/ObjectStorage.js';
24
18
 
25
- interface CurrentBucketState {
19
+ export interface CurrentBucketState {
26
20
  /** Bucket name */
27
21
  bucket: string;
28
22
  definitionId: BucketDefinitionId;
@@ -56,8 +50,6 @@ interface CurrentBucketState {
56
50
  opBytes: number;
57
51
  }
58
52
 
59
- type CompactClearProperties = 'op' | 'checksum' | 'target_op';
60
-
61
53
  export interface MongoCompactOptions extends storage.CompactOptions {}
62
54
 
63
55
  const DEFAULT_CLEAR_BATCH_LIMIT = 5000;
@@ -69,6 +61,15 @@ const DEFAULT_MIN_CHANGE_RATIO = 0.1;
69
61
  const DIRTY_BUCKET_SCAN_BATCH_SIZE = 2_000;
70
62
  /** This default is primarily for tests. */
71
63
  const DEFAULT_MEMORY_LIMIT_MB = 64;
64
+ const COMPACTION_RETRY_LIMIT = 3;
65
+ const COMPACTION_RETRY_DELAY_MS = 1_000;
66
+
67
+ export class ConcurrentCompactionError extends Error {
68
+ constructor(message: string) {
69
+ super(message);
70
+ this.name = 'ConcurrentCompactionError';
71
+ }
72
+ }
72
73
 
73
74
  export interface DirtyBucket {
74
75
  bucket: string;
@@ -78,7 +79,6 @@ export interface DirtyBucket {
78
79
  }
79
80
 
80
81
  export abstract class MongoCompactor {
81
- protected updates: mongo.AnyBulkWriteOperation<BucketDataDocumentGeneric>[] = [];
82
82
  protected bucketStateUpdates: mongo.AnyBulkWriteOperation<BucketStateDocumentBase>[] = [];
83
83
 
84
84
  protected readonly idLimitBytes: number;
@@ -90,6 +90,7 @@ export abstract class MongoCompactor {
90
90
  protected readonly minChangeRatio: number;
91
91
  protected readonly maxOpId: bigint;
92
92
  protected readonly buckets: string[] | undefined;
93
+ protected readonly deleteCheckpointRequestsBefore: Date | undefined;
93
94
  protected readonly signal?: AbortSignal;
94
95
  protected readonly group_id: number;
95
96
 
@@ -113,6 +114,7 @@ export abstract class MongoCompactor {
113
114
  this.minChangeRatio = options.minChangeRatio ?? DEFAULT_MIN_CHANGE_RATIO;
114
115
  this.maxOpId = options.maxOpId ?? 0n;
115
116
  this.buckets = options.compactBuckets;
117
+ this.deleteCheckpointRequestsBefore = options.deleteCheckpointRequestsBefore;
116
118
  this.signal = options.signal;
117
119
  this.logger = options.logger ?? defaultLogger;
118
120
  }
@@ -123,6 +125,8 @@ export abstract class MongoCompactor {
123
125
  * See /docs/storage/compacting-operations.md for details.
124
126
  */
125
127
  async compact() {
128
+ await this.deleteOldCheckpointRequests();
129
+
126
130
  if (this.buckets) {
127
131
  for (const bucket of this.buckets) {
128
132
  // We can make this more efficient later on by iterating through the buckets in a single query.
@@ -134,6 +138,25 @@ export abstract class MongoCompactor {
134
138
  }
135
139
  }
136
140
 
141
+ private async deleteOldCheckpointRequests() {
142
+ if (this.deleteCheckpointRequestsBefore == null) {
143
+ return;
144
+ }
145
+
146
+ this.signal?.throwIfAborted();
147
+ // The explicit $exists guarantees the query is a subset of the
148
+ // checkpoint_requested_at partial index's filter, so the planner can use it.
149
+ // Keep pending requests during replication lag so a retry doesn't advance the associated
150
+ // LSN even further.
151
+ await this.db.write_checkpoints.deleteMany({
152
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore },
153
+ processed_at_lsn: { $ne: null }
154
+ });
155
+ await this.db.custom_write_checkpoints.deleteMany({
156
+ checkpoint_requested_at: { $exists: true, $lt: this.deleteCheckpointRequestsBefore }
157
+ });
158
+ }
159
+
137
160
  /**
138
161
  * Subset of compact, only populating checksums where relevant.
139
162
  */
@@ -308,193 +331,54 @@ export abstract class MongoCompactor {
308
331
  /**
309
332
  * Compaction for a single bucket, with retries on failure.
310
333
  *
311
- * This covers against occasional network or other database errors during a long compact job.
334
+ * A compaction can race another compactor after its initial scan. Restarting
335
+ * the bucket is safe because each replacement is transactional. Object
336
+ * storage paths are also prepared with lifecycle markers, so a retry can
337
+ * safely overwrite or eventually clean up uploads from the failed attempt.
312
338
  */
313
339
  protected async compactSingleBucketRetried(bucket: string, definitionId: BucketDefinitionId | null = null) {
314
340
  let retryCount = 0;
315
- // Retry with exponential backoff up to 3 times on MongoDB errors.
316
341
  while (true) {
342
+ this.signal?.throwIfAborted();
343
+ // Do not carry queued bucket state writes from a failed attempt into the rescan.
344
+ this.bucketStateUpdates = [];
317
345
  try {
318
346
  await this.compactSingleBucket(bucket, definitionId);
319
- break;
347
+ return;
320
348
  } catch (e) {
321
- if (retryCount < 3 && isMongoServerError(e)) {
322
- this.logger.warn(`Error compacting bucket ${bucket}, retrying...`, e);
323
- retryCount++;
324
- await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
325
- } else {
349
+ if (this.signal?.aborted) {
326
350
  throw e;
327
351
  }
328
- }
329
- }
330
- }
331
352
 
332
- protected async compactSingleBucket(bucket: string, definitionId: BucketDefinitionId | null = null) {
333
- const idLimitBytes = this.idLimitBytes;
334
- const bucketContext = await this.getBucketDataContext(bucket, definitionId);
335
- if (bucketContext == null) {
336
- return;
337
- }
338
- const currentState: CurrentBucketState = {
339
- bucket,
340
- definitionId: bucketContext.key.definitionId,
341
- seen: new Map(),
342
- trackingSize: 0,
343
- lastNotPut: null,
344
- opsSincePut: 0,
345
- checksum: 0,
346
- opCount: 0,
347
- opBytes: 0
348
- };
349
-
350
- // Constant lower bound.
351
- const lowerBound = bucketContext.minId;
352
- // Upper bound is adjusted for each batch.
353
- let upperBound = bucketContext.maxId;
354
-
355
- // Paginate through bucket data in batches to avoid cursor timeouts.
356
- while (true) {
357
- this.signal?.throwIfAborted();
358
-
359
- // Query one batch at a time, to avoid cursor timeouts.
360
- const pipeline = [
361
- {
362
- $match: {
363
- _id: {
364
- $gte: lowerBound,
365
- $lt: upperBound
366
- }
367
- }
368
- },
369
- { $sort: { _id: -1 } },
370
- { $limit: this.moveBatchQueryLimit },
371
- {
372
- $project: {
373
- _id: 1,
374
- op: 1,
375
- table: 1,
376
- row_id: 1,
377
- source_table: 1,
378
- source_key: 1,
379
- checksum: 1,
380
- size: { $bsonSize: '$$ROOT' }
381
- }
382
- }
383
- ];
384
-
385
- const cursor = bucketContext.collection.aggregate<BucketDataDocumentGeneric & { size: number | bigint }>(
386
- pipeline,
387
- {
388
- // batchSize is 1 more than limit to auto-close the cursor.
389
- // See https://github.com/mongodb/node-mongodb-native/pull/4580
390
- batchSize: this.moveBatchQueryLimit + 1
391
- }
392
- );
393
- // We don't limit to a single batch here, since that often causes MongoDB to scan through more than it returns.
394
- // Instead, we load up to the limit.
395
- const rawBatch = await cursor.toArray();
396
- const batch = rawBatch.map((document) => {
397
- const { size, ...rest } = document;
398
- return {
399
- doc: bucketContext.fromPersistedDocument(rest),
400
- size
401
- };
402
- });
403
-
404
- if (batch.length == 0) {
405
- // We've reached the end.
406
- break;
407
- }
408
-
409
- // Reuse the exact collection _id value from Mongo for the next bound.
410
- upperBound = rawBatch[rawBatch.length - 1]._id;
411
-
412
- for (const { doc, size } of batch) {
413
- if (doc.o > this.maxOpId) {
414
- continue;
415
- }
416
-
417
- currentState.checksum = addChecksums(currentState.checksum, Number(doc.checksum));
418
- currentState.opCount += 1;
419
-
420
- let isPersistentPut = doc.op == 'PUT';
421
-
422
- currentState.opBytes += Number(size);
423
- if (doc.op == 'REMOVE' || doc.op == 'PUT') {
424
- const key = `${doc.table}/${doc.row_id}/${cacheKey(doc.source_table!, doc.source_key!)}`;
425
- const targetOp = currentState.seen.get(key);
426
- if (targetOp) {
427
- // Will convert to MOVE, so don't count as PUT.
428
- isPersistentPut = false;
429
-
430
- this.updates.push({
431
- updateOne: {
432
- filter: { _id: bucketContext.docId(doc.o) },
433
- update: {
434
- $set: {
435
- op: 'MOVE',
436
- target_op: targetOp
437
- },
438
- $unset: {
439
- source_table: 1,
440
- source_key: 1,
441
- table: 1,
442
- row_id: 1,
443
- data: 1
444
- }
445
- } satisfies mongo.UpdateFilter<BucketDataDocumentGeneric>
446
- }
447
- });
448
-
449
- // TODO: better estimate for this.
450
- currentState.opBytes += 200 - Number(size);
451
- } else if (currentState.trackingSize < idLimitBytes) {
452
- // flatstr reduces the memory usage by flattening the string.
453
- currentState.seen.set(utils.flatstr(key), doc.o);
454
- // length + 16 for the string
455
- // 24 for the bigint
456
- // 50 for map overhead
457
- // 50 for additional overhead
458
- currentState.trackingSize += key.length + 140;
459
- }
353
+ const retryReason = compactionRetryReason(e);
354
+ if (retryReason == null) {
355
+ throw e;
460
356
  }
461
-
462
- if (isPersistentPut) {
463
- currentState.lastNotPut = null;
464
- currentState.opsSincePut = 0;
465
- } else if (doc.op != 'CLEAR') {
466
- if (currentState.lastNotPut == null) {
467
- currentState.lastNotPut = doc.o;
468
- }
469
- currentState.opsSincePut += 1;
357
+ if (retryCount >= COMPACTION_RETRY_LIMIT) {
358
+ throw new Error(
359
+ `Failed to compact bucket ${bucket} after ${retryCount + 1} attempts (${retryReason}): ${errorMessage(e)}`,
360
+ { cause: e }
361
+ );
470
362
  }
471
363
 
472
- if (this.updates.length + this.bucketStateUpdates.length >= this.moveBatchLimit) {
473
- await this.flush(bucketContext);
474
- }
364
+ retryCount++;
365
+ const delay = COMPACTION_RETRY_DELAY_MS * 2 ** (retryCount - 1);
366
+ this.logger.warn(
367
+ `Error compacting bucket ${bucket} (${retryReason}); retrying in ${delay}ms ` +
368
+ `(attempt ${retryCount + 1}/${COMPACTION_RETRY_LIMIT + 1})`,
369
+ e
370
+ );
371
+ await timers.setTimeout(delay, undefined, this.signal ? { signal: this.signal } : undefined);
475
372
  }
476
-
477
- this.logger.info(`Processed batch of length ${batch.length} current bucket: ${bucket}`);
478
373
  }
479
-
480
- // Free memory before clearing the bucket.
481
- currentState.seen.clear();
482
- if (currentState.lastNotPut != null && currentState.opsSincePut >= 1) {
483
- this.logger.info(
484
- `Inserting CLEAR at ${this.group_id}:${bucket}:${currentState.lastNotPut} to remove ${currentState.opsSincePut} operations`
485
- );
486
- // Need flush() before clear().
487
- await this.flush(bucketContext);
488
- await this.clearBucket(currentState, bucketContext);
489
- }
490
-
491
- // Do this after clearBucket so we have accurate counts.
492
- this.updateBucketChecksums(currentState);
493
- // Need another flush after updateBucketChecksums().
494
- await this.flush(bucketContext);
495
374
  }
496
375
 
497
- protected collectBucketStateUpdates(state: CurrentBucketState): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
376
+ protected abstract compactSingleBucket(bucket: string, definitionId?: BucketDefinitionId | null): Promise<void>;
377
+
378
+ protected collectBucketStateUpdates(
379
+ state: CurrentBucketState,
380
+ compactedOpId: InternalOpId
381
+ ): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
498
382
  if (state.opCount < 0) {
499
383
  throw new ServiceAssertionError(
500
384
  `Invalid opCount: ${state.opCount} checksum ${state.checksum} opsSincePut: ${state.opsSincePut} maxOpId: ${this.maxOpId}`
@@ -506,7 +390,7 @@ export abstract class MongoCompactor {
506
390
  update: {
507
391
  $set: {
508
392
  compacted_state: {
509
- op_id: this.maxOpId,
393
+ op_id: compactedOpId,
510
394
  count: state.opCount,
511
395
  checksum: BigInt(state.checksum),
512
396
  bytes: state.opBytes
@@ -526,25 +410,11 @@ export abstract class MongoCompactor {
526
410
  };
527
411
  }
528
412
 
529
- protected updateBucketChecksums(state: CurrentBucketState) {
530
- this.bucketStateUpdates.push(this.collectBucketStateUpdates(state));
413
+ protected updateBucketChecksums(state: CurrentBucketState, compactedOpId: InternalOpId) {
414
+ this.bucketStateUpdates.push(this.collectBucketStateUpdates(state, compactedOpId));
531
415
  }
532
416
 
533
- protected async flush(col: SingleBucketStore) {
534
- if (this.updates.length > 0) {
535
- this.logger.info(`Compacting ${this.updates.length} ops`);
536
- await col.collection.bulkWrite(this.updates, {
537
- // Order is not important. Since checksums are not affected, these operations can happen in any order,
538
- // and it's fine if the operations are partially applied. Each individual operation is atomic.
539
- ordered: false
540
- });
541
- this.updates = [];
542
- }
543
-
544
- await this.flushBucketStateUpdates();
545
- }
546
-
547
- private async flushBucketStateUpdates() {
417
+ protected async flushBucketStateUpdates() {
548
418
  if (this.bucketStateUpdates.length > 0) {
549
419
  this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
550
420
  await this.writeBucketStateUpdates();
@@ -552,108 +422,6 @@ export abstract class MongoCompactor {
552
422
  }
553
423
  }
554
424
 
555
- /**
556
- * Perform a CLEAR compact for a bucket.
557
- *
558
- * @param currentState tracks the last non-PUT op, which will be converted to CLEAR.
559
- */
560
- protected async clearBucket(currentState: CurrentBucketState, col: SingleBucketStore) {
561
- const clearOp = currentState.lastNotPut!;
562
-
563
- const opFilter = {
564
- _id: {
565
- $gte: col.minId,
566
- $lte: col.docId(clearOp)
567
- }
568
- };
569
-
570
- const session = this.db.client.startSession();
571
- try {
572
- let done = false;
573
- while (!done) {
574
- this.signal?.throwIfAborted();
575
- let opCountDiff = 0;
576
- // Do the CLEAR operation in batches, with each batch a separate transaction.
577
- // The state after each batch is fully consistent.
578
- // We need a transaction per batch to make sure checksums stay consistent.
579
- await session.withTransaction(
580
- async () => {
581
- const query = col.collection.find<Pick<BucketDataDocumentGeneric, '_id' | CompactClearProperties>>(
582
- opFilter,
583
- {
584
- session,
585
- sort: { _id: 1 },
586
- projection: {
587
- _id: 1,
588
- op: 1,
589
- checksum: 1,
590
- target_op: 1
591
- },
592
- limit: this.clearBatchLimit
593
- }
594
- );
595
- let checksum = 0;
596
- let lastOp: Pick<BucketDataDoc, 'o' | CompactClearProperties> | null = null;
597
- let targetOp: bigint | null = null;
598
- let gotAnOp = false;
599
- let numberOfOpsToClear = 0;
600
- for await (const rawOp of query.stream()) {
601
- const op = col.fromPartialPersistedDocument(rawOp);
602
-
603
- if (op.op == 'MOVE' || op.op == 'REMOVE' || op.op == 'CLEAR') {
604
- checksum = utils.addChecksums(checksum, Number(op.checksum));
605
- lastOp = op;
606
- numberOfOpsToClear += 1;
607
- if (op.op != 'CLEAR') {
608
- gotAnOp = true;
609
- }
610
- if (op.target_op != null && (targetOp == null || op.target_op > targetOp)) {
611
- targetOp = op.target_op;
612
- }
613
- } else {
614
- throw new ReplicationAssertionError(`Unexpected ${op.op} operation at ${this.formatBucketDataKey(op)}`);
615
- }
616
- }
617
- if (!gotAnOp) {
618
- done = true;
619
- return;
620
- }
621
-
622
- this.logger.info(`Flushing CLEAR for ${numberOfOpsToClear} ops at ${lastOp?.o}`);
623
- await col.collection.deleteMany(
624
- {
625
- _id: {
626
- $gte: col.minId,
627
- $lte: col.docId(lastOp!.o)
628
- }
629
- },
630
- { session }
631
- );
632
-
633
- const op = col.toPersistedDocument({
634
- o: lastOp!.o,
635
- op: 'CLEAR',
636
- checksum: BigInt(checksum),
637
- data: null,
638
- target_op: targetOp
639
- });
640
- await col.collection.insertOne(op, { session });
641
-
642
- opCountDiff = -numberOfOpsToClear + 1;
643
- },
644
- {
645
- writeConcern: { w: 'majority' },
646
- readConcern: { level: 'snapshot' }
647
- }
648
- );
649
- // Update outside the transaction, since the transaction can be retried multiple times.
650
- currentState.opCount += opCountDiff;
651
- }
652
- } finally {
653
- await session.endSession();
654
- }
655
- }
656
-
657
425
  protected async updateChecksumsBatch(buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]) {
658
426
  const checksums = await this.computeChecksumsForBuckets(buckets);
659
427
  const definitionIdByBucket = new Map(buckets.map((bucket) => [bucket.bucket, bucket.definitionId]));
@@ -694,23 +462,31 @@ export abstract class MongoCompactor {
694
462
  await this.flushBucketStateUpdates();
695
463
  }
696
464
 
697
- protected formatBucketDataKey(doc: Pick<BucketDataDoc, 'bucketKey' | 'o'>) {
698
- return `${doc.bucketKey.replicationStreamId}:${doc.bucketKey.bucket}:${doc.o}`;
699
- }
700
-
701
465
  protected abstract writeBucketStateUpdates(): Promise<void>;
702
466
  protected abstract computeChecksumsForBuckets(
703
467
  buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]
704
468
  ): Promise<storage.PartialChecksumMap>;
705
469
  protected abstract bucketStateFilter(bucket: string, definitionId: BucketDefinitionId | null): mongo.Document;
706
-
707
- protected abstract getBucketDataContext(
708
- bucket: string,
709
- definitionId: BucketDefinitionId | null
710
- ): Promise<SingleBucketStore | null>;
711
470
  }
712
471
 
713
472
  export interface BucketDataCollectionContext<TBucketData extends mongo.Document> {
714
473
  bucketKey: BucketKey;
715
474
  collection: mongo.Collection<TBucketData>;
716
475
  }
476
+
477
+ function compactionRetryReason(error: unknown): string | null {
478
+ if (error instanceof ConcurrentCompactionError) {
479
+ return 'concurrent compaction';
480
+ }
481
+ if (isRetryableObjectStorageError(error)) {
482
+ return 'transient object storage failure';
483
+ }
484
+ if (isMongoServerError(error)) {
485
+ return 'MongoDB failure';
486
+ }
487
+ return null;
488
+ }
489
+
490
+ function errorMessage(error: unknown): string {
491
+ return error instanceof Error ? error.message : String(error);
492
+ }
@@ -1,10 +1,11 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
3
3
  import { POWERSYNC_VERSION, storage } from '@powersync/service-core';
4
- import { MongoStorageConfig } from '../../types/types.js';
4
+ import { MongoStorageConfig, normalizeClearBatchThrottleRate } from '../../types/types.js';
5
5
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
6
6
  import { MongoReportStorage } from '../MongoReportStorage.js';
7
7
  import { PowerSyncMongo } from './db.js';
8
+ import type { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
8
9
 
9
10
  export class MongoStorageProvider implements storage.StorageProvider {
10
11
  get type() {
@@ -23,6 +24,23 @@ export class MongoStorageProvider implements storage.StorageProvider {
23
24
  }
24
25
 
25
26
  const decodedConfig = MongoStorageConfig.decode(storage as any);
27
+
28
+ let objectStorage: ObjectStorage | undefined;
29
+ if (decodedConfig.object_storage?.type === 's3') {
30
+ // Dynamically import S3ObjectStorage to avoid loading AWS SDK unless needed.
31
+ const { S3ObjectStorage } = await import('./v3/object-storage/S3ObjectStorage.js');
32
+ objectStorage = new S3ObjectStorage({
33
+ bucket: decodedConfig.object_storage.bucket,
34
+ region: decodedConfig.object_storage.region,
35
+ prefix: decodedConfig.object_storage.prefix,
36
+ endpoint: decodedConfig.object_storage.endpoint,
37
+ forcePathStyle: decodedConfig.object_storage.force_path_style,
38
+ accessKeyId: decodedConfig.object_storage.access_key_id,
39
+ secretAccessKey: decodedConfig.object_storage.secret_access_key,
40
+ concurrencyLimit: decodedConfig.object_storage.concurrency_limit
41
+ });
42
+ }
43
+
26
44
  const client = lib_mongo.db.createMongoClient(decodedConfig, {
27
45
  powersyncVersion: POWERSYNC_VERSION,
28
46
  maxPoolSize: resolvedConfig.storage.max_pool_size ?? 8
@@ -49,11 +67,15 @@ export class MongoStorageProvider implements storage.StorageProvider {
49
67
  const syncStorageFactory = new MongoBucketStorage(database, {
50
68
  replicationStreamNamePrefix: resolvedConfig.slot_name_prefix,
51
69
  readPreference,
70
+ clearBatchThrottleRate: normalizeClearBatchThrottleRate(decodedConfig.clear_batch_throttle_rate),
52
71
  checksumCacheTtlMs: resolvedConfig.api_parameters.bucket_count_cache_ttl_minutes * 60_000,
53
72
  // Right now, only MongoDB source databases supports incremental reprocessing.
54
73
  // Remove this filter when we support it for other source databases.
55
74
  // This assumes a single source connection - revisit if we ever support multiple connections.
56
- supportsMultipleSyncConfigs: resolvedConfig.connections?.[0]?.type == lib_mongo.MONGO_CONNECTION_TYPE
75
+ supportsMultipleSyncConfigs: resolvedConfig.connections?.[0]?.type == lib_mongo.MONGO_CONNECTION_TYPE,
76
+
77
+ objectStorage,
78
+ inlineThresholdBytes: decodedConfig.object_storage?.inline_threshold_bytes
57
79
  });
58
80
 
59
81
  // Storage factory for reports
@@ -66,8 +88,15 @@ export class MongoStorageProvider implements storage.StorageProvider {
66
88
  await syncStorageFactory[Symbol.asyncDispose]();
67
89
  await client.close();
68
90
  },
69
- tearDown: () => {
91
+ tearDown: async () => {
70
92
  logger.info(`Tearing down storage: ${database.db.namespace}...`);
93
+ if (objectStorage != null) {
94
+ logger.info(
95
+ `Clearing object storage: ${decodedConfig.object_storage!.bucket}/${decodedConfig.object_storage!.prefix ?? ''}.`
96
+ );
97
+ const { objectCount } = await objectStorage.deletePrefix('bucket-data/');
98
+ logger.info(`Deleted ${objectCount} objects from object storage.`);
99
+ }
71
100
  return database.db.dropDatabase();
72
101
  },
73
102
  onFatalError: (callback) => {