@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
@@ -3,7 +3,7 @@ import { Logger, ReplicationAbortedError } from '@powersync/lib-services-framewo
3
3
  import { SingleSyncConfigBucketDefinitionMapping, storage } from '@powersync/service-core';
4
4
  import { BucketDefinitionId, ParameterIndexId, SyncConfig } from '@powersync/service-sync-rules';
5
5
  import * as bson from 'bson';
6
- import { idPrefixFilter, retryOnMongoMaxTimeMSExpired } from '../../../utils/util.js';
6
+ import { clearCollectionInIdRanges, idPrefixFilter } from '../../../utils/util.js';
7
7
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
8
8
  import {
9
9
  BucketStateDocumentV3,
@@ -11,6 +11,8 @@ import {
11
11
  SourceTableDocumentV3,
12
12
  SyncConfigDefinition
13
13
  } from './models.js';
14
+ import { ObjectStorage } from './object-storage/ObjectStorage.js';
15
+ import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
14
16
 
15
17
  type SyncConfigState = ReplicationStreamDocumentV3['sync_configs'][number];
16
18
 
@@ -34,6 +36,8 @@ export interface MongoStoppedSyncConfigCleanupOptions extends storage.CleanupSto
34
36
  replicationStreamId: number;
35
37
  db: VersionedPowerSyncMongoV3;
36
38
  logger: Logger;
39
+ clearBatchThrottleRate: number;
40
+ objectStorage?: ObjectStorage;
37
41
  }
38
42
 
39
43
  export class MongoStoppedSyncConfigCleanup {
@@ -41,16 +45,20 @@ export class MongoStoppedSyncConfigCleanup {
41
45
  private readonly replicationStreamId: number;
42
46
  private readonly signal: AbortSignal | undefined;
43
47
  private readonly logger: Logger;
48
+ private readonly objectStorage: ObjectStorage | undefined;
44
49
  private readonly defaultSchema: string;
45
50
  private readonly sourceConnectionTag: string;
51
+ private readonly clearBatchThrottleRate: number;
46
52
 
47
53
  constructor(options: MongoStoppedSyncConfigCleanupOptions) {
48
54
  this.db = options.db;
49
55
  this.replicationStreamId = options.replicationStreamId;
50
56
  this.signal = options.signal;
51
57
  this.logger = options.logger;
58
+ this.objectStorage = options.objectStorage;
52
59
  this.defaultSchema = options.defaultSchema;
53
60
  this.sourceConnectionTag = options.sourceConnectionTag;
61
+ this.clearBatchThrottleRate = options.clearBatchThrottleRate;
54
62
  }
55
63
 
56
64
  async run(): Promise<storage.CleanupStoppedSyncConfigsResult> {
@@ -371,9 +379,13 @@ export class MongoStoppedSyncConfigCleanup {
371
379
  }
372
380
 
373
381
  private async dropBucketDataCollections(definitionIds: BucketDefinitionId[]): Promise<number> {
382
+ const lifecycle = this.objectStorage
383
+ ? new ObjectStorageLifecycle(this.db, this.replicationStreamId, this.objectStorage)
384
+ : null;
374
385
  for (const definitionId of definitionIds) {
375
386
  this.throwIfAborted();
376
387
  await this.dropCollection(this.db.bucketData(this.replicationStreamId, definitionId));
388
+ await lifecycle?.deletePrefix(lifecycle.definitionPrefix(definitionId), { signal: this.signal });
377
389
  }
378
390
  return definitionIds.length;
379
391
  }
@@ -393,26 +405,16 @@ export class MongoStoppedSyncConfigCleanup {
393
405
  let deletedCount = 0;
394
406
  for (const definitionId of definitionIds) {
395
407
  this.throwIfAborted();
396
- const result = await retryOnMongoMaxTimeMSExpired(
397
- () =>
398
- collection.deleteMany(
399
- {
400
- _id: idPrefixFilter<BucketStateDocumentV3['_id']>({ d: definitionId }, ['b'])
401
- },
402
- { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
403
- ),
408
+ deletedCount += await clearCollectionInIdRanges(
409
+ this.logger,
410
+ 'bucket state',
411
+ collection,
404
412
  {
405
- signal: this.signal,
406
- abortMessage: 'Aborted stopped sync config cleanup',
407
- retryDelayMs: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS / 5,
408
- onRetry: () => {
409
- this.logger.info(
410
- `Cleared batch of bucket state in ${lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS}ms, continuing...`
411
- );
412
- }
413
- }
413
+ _id: idPrefixFilter<BucketStateDocumentV3['_id']>({ d: definitionId }, ['b'])
414
+ },
415
+ this.signal,
416
+ this.clearBatchThrottleRate
414
417
  );
415
- deletedCount += result.deletedCount;
416
418
  }
417
419
  return deletedCount;
418
420
  }
@@ -24,7 +24,7 @@ import { MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
24
24
  import { MongoParameterCompactor } from '../MongoParameterCompactor.js';
25
25
  import { MongoPersistedReplicationStream } from '../MongoPersistedReplicationStream.js';
26
26
  import { MongoSyncBucketStorage, MongoSyncBucketStorageOptions } from '../MongoSyncBucketStorage.js';
27
- import { loadBucketDataDocument } from './bucket-format.js';
27
+ import { loadBucketDataDocument, maxOpId } from './bucket-format.js';
28
28
  import {
29
29
  BucketDataDocumentV3,
30
30
  BucketParameterDocumentV3,
@@ -37,12 +37,16 @@ import { MongoBucketBatchV3 } from './MongoBucketBatchV3.js';
37
37
  import { MongoChecksumsV3 } from './MongoChecksumsV3.js';
38
38
  import { MongoCompactorV3 } from './MongoCompactorV3.js';
39
39
  import { MongoStoppedSyncConfigCleanup } from './MongoStoppedSyncConfigCleanup.js';
40
+ import { hydrateBucketDataDocuments } from './object-storage/BucketDataObjectStorage.js';
41
+ import { ObjectStorage } from './object-storage/ObjectStorage.js';
42
+ import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
40
43
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
41
44
 
42
45
  export interface MongoSyncBucketStorageContextV3 {
43
46
  db: VersionedPowerSyncMongoV3;
44
47
  replicationStreamId: number;
45
48
  readPreference?: mongo.ReadPreference;
49
+ objectStorage: ObjectStorage | undefined;
46
50
  /**
47
51
  * Persisted mapping of the single sync config that read operations are served from.
48
52
  *
@@ -52,47 +56,53 @@ export interface MongoSyncBucketStorageContextV3 {
52
56
  readonly mapping: SingleSyncConfigBucketDefinitionMapping;
53
57
  }
54
58
 
55
- function* walkDocumentOps(
56
- data: BucketDataDoc[],
57
- documentOpCounts: number[],
58
- documentSizes: number[]
59
- ): Generator<{ row: BucketDataDoc; docIndex: number; isLastOpInDocument: boolean }> {
60
- let opIndex = 0;
61
- for (const [docIndex, opCount] of documentOpCounts.entries()) {
62
- for (let i = 0; i < opCount; i++) {
63
- yield { row: data[opIndex++], docIndex, isLastOpInDocument: i === opCount - 1 };
59
+ const BUCKET_DATA_FETCH_BATCH_LIMIT_BYTES = 16 * 1024 * 1024;
60
+
61
+ /**
62
+ * Keep the documents hydrated for one sync response within a bounded payload or slightly higher.
63
+ *
64
+ * This deserializes on-demand, so no deserialization is performed for discarded data.
65
+ */
66
+ function cutBucketDataBatch(rawDocuments: Buffer[]): {
67
+ documents: BucketDataDocumentV3[];
68
+ wasCut: boolean;
69
+ } {
70
+ let cumulativeBytes = 0;
71
+ let documents: BucketDataDocumentV3[] = [];
72
+ for (const raw of rawDocuments) {
73
+ const doc = bson.deserialize(raw, storage.BSON_DESERIALIZE_INTERNAL_OPTIONS) as BucketDataDocumentV3;
74
+ documents.push(doc);
75
+ cumulativeBytes += doc.size;
76
+ if (cumulativeBytes > BUCKET_DATA_FETCH_BATCH_LIMIT_BYTES) {
77
+ return {
78
+ documents,
79
+ wasCut: documents.length < rawDocuments.length
80
+ };
64
81
  }
65
82
  }
83
+ return { documents, wasCut: false };
66
84
  }
67
85
 
68
86
  function extractRowsFromDocument(
69
87
  doc: BucketDataDocumentV3,
70
88
  context: { replicationStreamId: number; definitionId: string },
71
- bucketMap: Map<string, InternalOpId>,
72
- endOpId: InternalOpId,
73
- remainingLimit: number
74
- ): { rows: BucketDataDoc[]; remainingLimit: number; limitReached: boolean } {
89
+ bucketStart: InternalOpId,
90
+ endOpId: InternalOpId
91
+ ): BucketDataDoc[] {
75
92
  const rows: BucketDataDoc[] = [];
76
93
  for (const row of loadBucketDataDocument(context, doc)) {
77
- const bucket = row.bucketKey.bucket;
78
- const bucketStart = bucketMap.get(bucket);
79
- if (bucketStart == null) {
80
- throw new Error(`data for unexpected bucket: ${bucket}`);
81
- }
94
+ // In theory a binary search could be faster than a linear scan to find the start.
95
+ // In practice, most cases should not filter out anything here.
82
96
  if (row.o <= bucketStart) {
83
97
  continue;
84
98
  }
85
99
  if (row.o > endOpId) {
86
- continue;
100
+ break;
87
101
  }
88
102
 
89
103
  rows.push(row);
90
- remainingLimit--;
91
- if (remainingLimit <= 0) {
92
- return { rows, remainingLimit, limitReached: true };
93
- }
94
104
  }
95
- return { rows, remainingLimit, limitReached: false };
105
+ return rows;
96
106
  }
97
107
 
98
108
  export class MongoSyncBucketStorageV3 extends MongoSyncBucketStorage {
@@ -337,6 +347,7 @@ export class MongoSyncBucketStorageV3 extends MongoSyncBucketStorage {
337
347
  const self = this;
338
348
  return {
339
349
  db: this.db,
350
+ objectStorage: this.objectStorage,
340
351
  replicationStreamId: this.replicationStreamId,
341
352
  readPreference: this.readPreference,
342
353
  get mapping() {
@@ -357,14 +368,27 @@ export class MongoSyncBucketStorageV3 extends MongoSyncBucketStorage {
357
368
  checkpoint: MongoSyncBucketStorageCheckpoint,
358
369
  dataBuckets: storage.BucketDataRequest[],
359
370
  options?: storage.BucketDataBatchOptions
360
- ): AsyncIterable<storage.SyncBucketDataChunk> {
371
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd> {
361
372
  return getBucketDataBatchV3(this.versionContext, checkpoint, dataBuckets, options);
362
373
  }
363
374
 
364
- protected async clearBucketData(_signal?: AbortSignal): Promise<void> {
375
+ protected async clearBucketData(signal?: AbortSignal): Promise<void> {
365
376
  for (const collection of await this.db.listBucketDataCollections(this.replicationStreamId)) {
366
377
  await collection.drop();
367
378
  }
379
+ if (this.objectStorage) {
380
+ const lifecycle = new ObjectStorageLifecycle(this.db, this.replicationStreamId, this.objectStorage);
381
+ await lifecycle.deletePrefix(lifecycle.streamPrefix(), { signal });
382
+ }
383
+ await this.db
384
+ .pendingObjectStorageDeletes(this.replicationStreamId)
385
+ .drop({ maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS })
386
+ .catch((error) => {
387
+ if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
388
+ return;
389
+ }
390
+ throw error;
391
+ });
368
392
  }
369
393
 
370
394
  protected async clearParameterIndexes(_signal?: AbortSignal): Promise<void> {
@@ -412,7 +436,9 @@ export class MongoSyncBucketStorageV3 extends MongoSyncBucketStorage {
412
436
  signal: options.signal,
413
437
  logger: options.logger ?? this.logger,
414
438
  defaultSchema: options.defaultSchema,
415
- sourceConnectionTag: options.sourceConnectionTag
439
+ sourceConnectionTag: options.sourceConnectionTag,
440
+ objectStorage: this.objectStorage,
441
+ clearBatchThrottleRate: this.clearBatchThrottleRate
416
442
  }).run();
417
443
  }
418
444
 
@@ -534,7 +560,7 @@ export async function* getBucketDataBatchV3(
534
560
  checkpoint: MongoSyncBucketStorageCheckpoint,
535
561
  dataBuckets: storage.BucketDataRequest[],
536
562
  options?: storage.BucketDataBatchOptions
537
- ): AsyncIterable<storage.SyncBucketDataChunk> {
563
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd> {
538
564
  if (dataBuckets.length == 0) {
539
565
  return;
540
566
  }
@@ -553,13 +579,15 @@ export async function* getBucketDataBatchV3(
553
579
 
554
580
  if (session != null) {
555
581
  session.advanceOperationTime(checkpoint.snapshotTime);
582
+ session.advanceClusterTime(checkpoint.clusterTime);
556
583
  }
557
584
 
558
585
  const batchLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
559
586
  const chunkSizeLimitBytes = options?.chunkLimitBytes ?? storage.DEFAULT_DOCUMENT_CHUNK_LIMIT_BYTES;
560
587
  const end = checkpoint.checkpoint;
561
- let remainingLimit = batchLimit;
562
588
 
589
+ // Group requests by definition, so that we can query each definition's bucket data in a single query.
590
+ // We only return the results of a single query per batch.
563
591
  const requestsByDefinition = new Map<string, storage.BucketDataRequest[]>();
564
592
  for (const request of dataBuckets) {
565
593
  const definitionId = ctx.mapping.bucketSourceId(request.source);
@@ -570,9 +598,6 @@ export async function* getBucketDataBatchV3(
570
598
 
571
599
  const definitionGroups = Array.from(requestsByDefinition.entries());
572
600
  for (const [groupIndex, [definitionId, requests]] of definitionGroups.entries()) {
573
- if (remainingLimit <= 0) {
574
- break;
575
- }
576
601
  const hasLaterDefinitionGroups = groupIndex < definitionGroups.length - 1;
577
602
  const bucketMap = new Map(requests.map((request) => [request.bucket, request.start]));
578
603
  const filters = Array.from(bucketMap.entries()).map(([bucket, start]) => ({
@@ -588,9 +613,8 @@ export async function* getBucketDataBatchV3(
588
613
  // MongoDB Filter<T> doesn't accept the $or operator in its type.
589
614
  const filter = { $or: filters } as unknown as mongo.Filter<BucketDataDocumentV3>;
590
615
  const context = { replicationStreamId: ctx.replicationStreamId, definitionId };
591
- const limit = remainingLimit;
592
616
 
593
- const cursorOptions = { limit: remainingLimit, batchSize: remainingLimit + 1 };
617
+ const cursorOptions = { limit: batchLimit, batchSize: batchLimit + 1 };
594
618
 
595
619
  // raw: true returns Buffers, but the driver typing doesn't reflect that
596
620
  // without an explicit cast to FindCursor<Buffer>.
@@ -608,88 +632,44 @@ export async function* getBucketDataBatchV3(
608
632
  throw lib_mongo.mapQueryError(e, 'while reading bucket data');
609
633
  });
610
634
 
611
- if (cursorOptions.limit != null && rawData.length >= cursorOptions.limit) {
635
+ if (rawData.length >= cursorOptions.limit) {
612
636
  hasMore = true;
613
637
  }
614
638
 
615
- const data: BucketDataDoc[] = [];
616
- const documentOpCounts: number[] = [];
617
- const documentSizes: number[] = [];
618
- let sharedRemainingLimit = limit;
619
- let limitReached = false;
620
- // Buckets whose matched document contributed no rows after filtering.
621
- const completeEmptyBuckets = new Set<string>();
622
-
623
- for (const raw of rawData) {
624
- const doc = bson.deserialize(raw, storage.BSON_DESERIALIZE_INTERNAL_OPTIONS) as BucketDataDocumentV3;
625
- const {
626
- rows,
627
- remainingLimit,
628
- limitReached: docLimitReached
629
- } = extractRowsFromDocument(doc, context, bucketMap, end, sharedRemainingLimit);
630
- if (rows.length == 0) {
631
- // The document straddles the requested (start, end] window: it matched the
632
- // query, but none of its ops are in range. Since its _id.o (max op) must be
633
- // > end (any op <= end would have been > start, and thus in range), and
634
- // document ranges per bucket are disjoint, no later document for this bucket
635
- // can match either. The bucket is complete through the checkpoint.
636
- completeEmptyBuckets.add(doc._id.b);
637
- }
638
- data.push(...rows);
639
- documentOpCounts.push(rows.length);
640
- documentSizes.push(raw.byteLength);
641
- sharedRemainingLimit = remainingLimit;
642
- if (docLimitReached) {
643
- limitReached = true;
644
- break;
645
- }
646
- }
647
-
648
- const batchHasMore = hasMore || limitReached;
649
-
650
- // Empty chunks are not forwarded to clients, but report progress to the caller:
651
- // the bucket's position advances to the checkpoint, so it is not re-requested.
652
- // If the batch produced no data at all, the last empty chunk also carries the
653
- // has_more signal, so the caller re-requests the remaining buckets instead of
654
- // treating an all-filtered batch as the end of the stream.
655
- const emptyBuckets = Array.from(completeEmptyBuckets);
656
- for (const [index, bucket] of emptyBuckets.entries()) {
657
- const startOpId = bucketMap.get(bucket);
658
- if (startOpId == null) {
659
- throw new ServiceAssertionError(`data for unexpected bucket: ${bucket}`);
660
- }
661
- const isLastChunkOfBatch = data.length == 0 && index == emptyBuckets.length - 1;
662
- yield {
663
- chunkData: {
664
- bucket,
665
- after: internalToExternalOpId(startOpId),
666
- has_more: isLastChunkOfBatch && batchHasMore,
667
- data: [],
668
- next_after: internalToExternalOpId(end)
669
- },
670
- targetOp: null
671
- };
672
- }
673
-
674
- if (data.length == 0) {
675
- if (batchHasMore) {
676
- // The remaining documents are read in the next round, after the caller has
677
- // advanced the positions of the empty buckets above.
678
- return;
679
- }
680
- continue;
639
+ // Deserialize the raw documents and cut the batch to a bounded size. Any data not
640
+ // making the cut will be read in the next round, using a fresh query.
641
+ const cutBatch = cutBucketDataBatch(rawData);
642
+ const docs = cutBatch.documents;
643
+ if (cutBatch.wasCut) {
644
+ hasMore = true;
681
645
  }
682
646
 
683
- remainingLimit -= data.length;
647
+ // Hydrate any operations from object storage.
648
+ // In the future we can do this in a more pipelined fashion, but for now we hydrate
649
+ // the entire batch at once.
650
+ await hydrateBucketDataDocuments(docs, ctx.objectStorage, { signal: options?.signal });
684
651
 
685
652
  let currentChunkSizeBytes = 0;
686
653
  let currentChunk: utils.SyncBucketData | null = null;
687
654
  let targetOp: InternalOpId | null = null;
655
+ let seenBuckets = new Set<string>();
656
+ const batchHasMore = hasMore;
657
+
658
+ for (const doc of docs) {
659
+ const bucket = doc._id.b;
660
+ seenBuckets.add(bucket);
661
+ const bucketStart = bucketMap.get(bucket);
662
+ if (bucketStart == null) {
663
+ throw new ServiceAssertionError(`data for unexpected bucket: ${bucket}`);
664
+ }
688
665
 
689
- for (const { row, docIndex, isLastOpInDocument } of walkDocumentOps(data, documentOpCounts, documentSizes)) {
690
- const bucket = row.bucketKey.bucket;
691
-
692
- if (currentChunk == null || currentChunk.bucket != bucket || currentChunkSizeBytes >= chunkSizeLimitBytes) {
666
+ // Reached a new bucket or size limit: yield the current chunk and start a new one.
667
+ if (
668
+ currentChunk == null ||
669
+ currentChunk.bucket != bucket ||
670
+ currentChunkSizeBytes >= chunkSizeLimitBytes ||
671
+ currentChunk.data.length >= batchLimit
672
+ ) {
693
673
  let start: ProtocolOpId | undefined = undefined;
694
674
  if (currentChunk != null) {
695
675
  if (currentChunk.bucket == bucket) {
@@ -705,12 +685,9 @@ export async function* getBucketDataBatchV3(
705
685
  }
706
686
 
707
687
  if (start == null) {
708
- const startOpId = bucketMap.get(bucket);
709
- if (startOpId == null) {
710
- throw new Error(`data for unexpected bucket: ${bucket}`);
711
- }
712
- start = internalToExternalOpId(startOpId);
688
+ start = internalToExternalOpId(bucketStart);
713
689
  }
690
+
714
691
  currentChunk = {
715
692
  bucket,
716
693
  after: start,
@@ -720,27 +697,47 @@ export async function* getBucketDataBatchV3(
720
697
  };
721
698
  }
722
699
 
723
- const entry = mapOpEntry(row);
724
- if (row.target_op != null && (targetOp == null || row.target_op > targetOp)) {
725
- targetOp = row.target_op;
726
- }
727
-
728
- currentChunk.data.push(entry);
729
- currentChunk.next_after = entry.op_id;
730
-
731
- if (isLastOpInDocument) {
732
- currentChunkSizeBytes += documentSizes[docIndex];
733
- }
700
+ const rows = extractRowsFromDocument(doc, context, bucketStart, end);
701
+ currentChunk.data.push(...rows.map(mapOpEntry));
702
+ currentChunk.next_after = currentChunk.data.at(-1)?.op_id ?? internalToExternalOpId(end);
703
+ targetOp = maxOpId(targetOp, doc.target_op);
704
+ currentChunkSizeBytes += doc.size;
734
705
  }
735
706
 
736
707
  if (currentChunk != null) {
737
708
  const yieldChunk = currentChunk;
738
- yieldChunk.has_more = batchHasMore || (remainingLimit <= 0 && hasLaterDefinitionGroups);
709
+ // The last chunk may contain more data that was cut in this batch.
710
+ yieldChunk.has_more = batchHasMore;
739
711
  yield { chunkData: yieldChunk, targetOp };
740
712
  }
741
713
 
742
- if (batchHasMore || remainingLimit <= 0) {
743
- return;
714
+ if (!batchHasMore) {
715
+ for (const bucket of bucketMap.keys()) {
716
+ if (!seenBuckets.has(bucket)) {
717
+ // We processed everything for this definition group, but this bucket had no data in the batch.
718
+ // Yield an empty chunk to indicate that it is complete.
719
+ // This prevents re-querying the same bucket in the next batch.
720
+ yield {
721
+ chunkData: {
722
+ bucket,
723
+ after: internalToExternalOpId(bucketMap.get(bucket)!),
724
+ has_more: false,
725
+ data: [],
726
+ next_after: internalToExternalOpId(end)
727
+ },
728
+ targetOp
729
+ };
730
+ }
731
+ }
732
+ }
733
+
734
+ if (currentChunk != null) {
735
+ // We yielded data in this group (aside from empty buckets).
736
+ // Return to the caller to allow them to process it before continuing to the next group.
737
+ yield { hasMore: batchHasMore || hasLaterDefinitionGroups };
738
+ break;
739
+ } else {
740
+ // No data in this definition group - continue in the next group.
744
741
  }
745
742
  }
746
743
  }
@@ -1,6 +1,6 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { InternalOpId, storage } from '@powersync/service-core';
3
+ import { BucketDefinitionMapping, InternalOpId, storage } from '@powersync/service-core';
4
4
  import { BucketDataSource, BucketDefinitionId } from '@powersync/service-sync-rules';
5
5
  import * as bson from 'bson';
6
6
  import { mongoTableId } from '../../../utils/util.js';
@@ -8,6 +8,7 @@ import { BucketDataDoc } from '../common/BucketDataDoc.js';
8
8
  import {
9
9
  BucketStateUpdate,
10
10
  PersistedBatch,
11
+ PersistedBatchOptions,
11
12
  SaveParameterDataOptions,
12
13
  UpsertCurrentDataOptions
13
14
  } from '../common/PersistedBatch.js';
@@ -22,14 +23,29 @@ import {
22
23
  SourceTableDocumentV3,
23
24
  taggedBucketParameterDocumentToTagged
24
25
  } from './models.js';
26
+ import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
25
27
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
26
28
 
27
29
  export class PersistedBatchV3 extends PersistedBatch {
28
30
  currentData: { sourceTableId: bson.ObjectId; operation: mongo.AnyBulkWriteOperation<CurrentDataDocumentV3> }[] = [];
29
31
  sourceTablePendingDeletes = new Map<string, InternalOpId>();
32
+ protected readonly objectStorageLifecycle?: ObjectStorageLifecycle;
30
33
 
31
34
  declare protected readonly db: VersionedPowerSyncMongoV3;
32
35
 
36
+ constructor(
37
+ db: VersionedPowerSyncMongoV3,
38
+ group_id: number,
39
+ mapping: BucketDefinitionMapping,
40
+ writtenSize: number,
41
+ options?: PersistedBatchOptions
42
+ ) {
43
+ super(db, group_id, mapping, writtenSize, options);
44
+ if (this.objectStorage) {
45
+ this.objectStorageLifecycle = new ObjectStorageLifecycle(this.db, this.group_id, this.objectStorage);
46
+ }
47
+ }
48
+
33
49
  // Abstract override from PersistedBatch (V3-specific error message)
34
50
 
35
51
  protected override checkDefinitionId(definitionId: BucketDefinitionId | null): BucketDefinitionId {
@@ -209,26 +225,63 @@ export class PersistedBatchV3 extends PersistedBatch {
209
225
  operationsByDefinition.set(document.bucketKey.definitionId, existing);
210
226
  }
211
227
 
212
- for (const [definitionId, documents] of operationsByDefinition.entries()) {
213
- const operationsByBucket = new Map<string, BucketDataDoc[]>();
214
- for (const document of documents) {
215
- const existing = operationsByBucket.get(document.bucketKey.bucket) ?? [];
216
- existing.push(document);
217
- operationsByBucket.set(document.bucketKey.bucket, existing);
218
- }
228
+ let uploadCount = 0;
229
+ const plans = Array.from(operationsByDefinition, ([definitionId, documents]) => {
230
+ const operationsByBucket = Map.groupBy(documents, (document) => document.bucketKey.bucket);
231
+ const lifecycle = this.objectStorageLifecycle;
232
+ const createInserts: (() => Promise<mongo.AnyBulkWriteOperation<BucketDataDocumentV3>>)[] = [];
233
+
234
+ for (const [bucket, ops] of operationsByBucket) {
235
+ for (const chunk of chunkBucketData(ops)) {
236
+ const serialized = serializeBucketData(bucket, chunk);
237
+ if (lifecycle == null || serialized.size <= this.inlineThresholdBytes) {
238
+ createInserts.push(async () => ({
239
+ insertOne: {
240
+ document: serialized
241
+ }
242
+ }));
243
+ continue;
244
+ }
219
245
 
220
- const inserts: mongo.AnyBulkWriteOperation<BucketDataDocumentV3>[] = [];
221
- for (const [bucket, ops] of operationsByBucket.entries()) {
222
- const chunks = chunkBucketData(ops);
223
- for (const chunk of chunks) {
224
- inserts.push({
225
- insertOne: {
226
- document: serializeBucketData(bucket, chunk)
227
- }
246
+ uploadCount += 1;
247
+ createInserts.push(async () => {
248
+ const minOp = chunk[0].o;
249
+ const maxOp = chunk[chunk.length - 1].o;
250
+ const { ops: bucketOps, ...metadata } = serialized;
251
+ const path = lifecycle.allocatePath(definitionId, bucket, minOp, maxOp);
252
+ const { fileSize } = await lifecycle.bucketData.store(path, bucketOps!);
253
+ return {
254
+ insertOne: {
255
+ document: {
256
+ ...metadata,
257
+ storage_ref: {
258
+ path,
259
+ file_size: fileSize
260
+ }
261
+ }
262
+ }
263
+ };
228
264
  });
229
265
  }
230
266
  }
231
267
 
268
+ return { definitionId, createInserts };
269
+ });
270
+
271
+ const createAllInserts = () =>
272
+ Promise.all(
273
+ plans.map(async ({ definitionId, createInserts }) => ({
274
+ definitionId,
275
+ inserts: await Promise.all(createInserts.map((createInsert) => createInsert()))
276
+ }))
277
+ );
278
+
279
+ // S3ObjectStorage applies one shared concurrency limit across all callers,
280
+ // so replication can schedule its uploads together without creating a
281
+ // separate limiter here.
282
+ const writes = await createAllInserts();
283
+
284
+ for (const { definitionId, inserts } of writes) {
232
285
  if (inserts.length > 0) {
233
286
  await this.db.bucketData(this.group_id, definitionId).bulkWrite(inserts, {
234
287
  session,
@@ -7,6 +7,7 @@ import {
7
7
  BucketParameterDocumentV3,
8
8
  BucketStateDocumentV3,
9
9
  CurrentDataDocumentV3,
10
+ ObjectStorageDeletionMarker,
10
11
  SourceTableDocumentV3,
11
12
  SyncConfigDefinition
12
13
  } from './models.js';
@@ -69,6 +70,10 @@ export class VersionedPowerSyncMongoV3 extends BaseVersionedPowerSyncMongo {
69
70
  return this.db.collection<BucketDataDocumentV3>(`bucket_data_${replicationStreamId}_${definitionId}`);
70
71
  }
71
72
 
73
+ pendingObjectStorageDeletes(replicationStreamId: number): mongo.Collection<ObjectStorageDeletionMarker> {
74
+ return this.db.collection<ObjectStorageDeletionMarker>(`pending_object_storage_deletes_${replicationStreamId}`);
75
+ }
76
+
72
77
  listBucketDataCollections(replicationStreamId: number) {
73
78
  return this.listCollectionsByPrefix(`bucket_data_${replicationStreamId}_`);
74
79
  }