@powersync/service-module-mongodb-storage 0.18.3 → 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 (137) hide show
  1. package/CHANGELOG.md +34 -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/MongoCompactor.d.ts +15 -19
  11. package/dist/storage/implementation/MongoCompactor.js +68 -260
  12. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  13. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  14. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  15. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +10 -4
  16. package/dist/storage/implementation/MongoSyncBucketStorage.js +13 -12
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  18. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  19. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  20. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  21. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  22. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  23. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  24. package/dist/storage/implementation/models.d.ts +16 -0
  25. package/dist/storage/implementation/models.js.map +1 -1
  26. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  27. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  28. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  29. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +19 -11
  30. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  31. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  32. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  33. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  34. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  35. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  36. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  37. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  38. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  39. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  40. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  41. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  43. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  44. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  46. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  47. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  49. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +108 -106
  50. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  51. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  52. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  53. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  54. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  55. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  56. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  57. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  58. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  59. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  60. package/dist/storage/implementation/v3/chunking.js +3 -1
  61. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  62. package/dist/storage/implementation/v3/models.d.ts +38 -1
  63. package/dist/storage/implementation/v3/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  65. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  66. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  67. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  68. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  69. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  70. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  73. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  74. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  75. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  76. package/dist/types/types.d.ts +35 -0
  77. package/dist/types/types.js +35 -1
  78. package/dist/types/types.js.map +1 -1
  79. package/dist/utils/test-utils.js +3 -1
  80. package/dist/utils/test-utils.js.map +1 -1
  81. package/dist/utils/util.d.ts +8 -1
  82. package/dist/utils/util.js +125 -2
  83. package/dist/utils/util.js.map +1 -1
  84. package/package.json +10 -7
  85. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  86. package/src/storage/MongoBucketStorage.ts +10 -2
  87. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  88. package/src/storage/implementation/MongoCompactor.ts +90 -314
  89. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  90. package/src/storage/implementation/MongoSyncBucketStorage.ts +23 -22
  91. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  92. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  93. package/src/storage/implementation/models.ts +16 -0
  94. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  95. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +53 -40
  96. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  97. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  98. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  99. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  100. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  101. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  102. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +122 -126
  103. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  104. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  105. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  106. package/src/storage/implementation/v3/chunking.ts +3 -1
  107. package/src/storage/implementation/v3/models.ts +40 -1
  108. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  109. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  110. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  111. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  112. package/src/types/types.ts +42 -1
  113. package/src/utils/test-utils.ts +3 -1
  114. package/src/utils/util.ts +182 -4
  115. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  116. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  117. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  118. package/test/src/helpers/s3TestFactory.ts +69 -0
  119. package/test/src/object_storage_lifecycle.test.ts +59 -0
  120. package/test/src/setup.ts +6 -1
  121. package/test/src/storage.test.ts +216 -2
  122. package/test/src/storage_compacting.test.ts +176 -187
  123. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  124. package/test/src/storage_s3_checksums.test.ts +191 -0
  125. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  126. package/test/src/storage_s3_reading.test.ts +526 -0
  127. package/test/src/storage_s3_writing.test.ts +135 -0
  128. package/test/src/storage_sync.test.ts +95 -5
  129. package/tsconfig.tsbuildinfo +1 -1
  130. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  131. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  132. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  133. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  134. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  135. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  136. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  137. 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
  }
@@ -559,8 +585,9 @@ export async function* getBucketDataBatchV3(
559
585
  const batchLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
560
586
  const chunkSizeLimitBytes = options?.chunkLimitBytes ?? storage.DEFAULT_DOCUMENT_CHUNK_LIMIT_BYTES;
561
587
  const end = checkpoint.checkpoint;
562
- let remainingLimit = batchLimit;
563
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.
564
591
  const requestsByDefinition = new Map<string, storage.BucketDataRequest[]>();
565
592
  for (const request of dataBuckets) {
566
593
  const definitionId = ctx.mapping.bucketSourceId(request.source);
@@ -571,9 +598,6 @@ export async function* getBucketDataBatchV3(
571
598
 
572
599
  const definitionGroups = Array.from(requestsByDefinition.entries());
573
600
  for (const [groupIndex, [definitionId, requests]] of definitionGroups.entries()) {
574
- if (remainingLimit <= 0) {
575
- break;
576
- }
577
601
  const hasLaterDefinitionGroups = groupIndex < definitionGroups.length - 1;
578
602
  const bucketMap = new Map(requests.map((request) => [request.bucket, request.start]));
579
603
  const filters = Array.from(bucketMap.entries()).map(([bucket, start]) => ({
@@ -589,9 +613,8 @@ export async function* getBucketDataBatchV3(
589
613
  // MongoDB Filter<T> doesn't accept the $or operator in its type.
590
614
  const filter = { $or: filters } as unknown as mongo.Filter<BucketDataDocumentV3>;
591
615
  const context = { replicationStreamId: ctx.replicationStreamId, definitionId };
592
- const limit = remainingLimit;
593
616
 
594
- const cursorOptions = { limit: remainingLimit, batchSize: remainingLimit + 1 };
617
+ const cursorOptions = { limit: batchLimit, batchSize: batchLimit + 1 };
595
618
 
596
619
  // raw: true returns Buffers, but the driver typing doesn't reflect that
597
620
  // without an explicit cast to FindCursor<Buffer>.
@@ -609,88 +632,44 @@ export async function* getBucketDataBatchV3(
609
632
  throw lib_mongo.mapQueryError(e, 'while reading bucket data');
610
633
  });
611
634
 
612
- if (cursorOptions.limit != null && rawData.length >= cursorOptions.limit) {
635
+ if (rawData.length >= cursorOptions.limit) {
613
636
  hasMore = true;
614
637
  }
615
638
 
616
- const data: BucketDataDoc[] = [];
617
- const documentOpCounts: number[] = [];
618
- const documentSizes: number[] = [];
619
- let sharedRemainingLimit = limit;
620
- let limitReached = false;
621
- // Buckets whose matched document contributed no rows after filtering.
622
- const completeEmptyBuckets = new Set<string>();
623
-
624
- for (const raw of rawData) {
625
- const doc = bson.deserialize(raw, storage.BSON_DESERIALIZE_INTERNAL_OPTIONS) as BucketDataDocumentV3;
626
- const {
627
- rows,
628
- remainingLimit,
629
- limitReached: docLimitReached
630
- } = extractRowsFromDocument(doc, context, bucketMap, end, sharedRemainingLimit);
631
- if (rows.length == 0) {
632
- // The document straddles the requested (start, end] window: it matched the
633
- // query, but none of its ops are in range. Since its _id.o (max op) must be
634
- // > end (any op <= end would have been > start, and thus in range), and
635
- // document ranges per bucket are disjoint, no later document for this bucket
636
- // can match either. The bucket is complete through the checkpoint.
637
- completeEmptyBuckets.add(doc._id.b);
638
- }
639
- data.push(...rows);
640
- documentOpCounts.push(rows.length);
641
- documentSizes.push(raw.byteLength);
642
- sharedRemainingLimit = remainingLimit;
643
- if (docLimitReached) {
644
- limitReached = true;
645
- break;
646
- }
647
- }
648
-
649
- const batchHasMore = hasMore || limitReached;
650
-
651
- // Empty chunks are not forwarded to clients, but report progress to the caller:
652
- // the bucket's position advances to the checkpoint, so it is not re-requested.
653
- // If the batch produced no data at all, the last empty chunk also carries the
654
- // has_more signal, so the caller re-requests the remaining buckets instead of
655
- // treating an all-filtered batch as the end of the stream.
656
- const emptyBuckets = Array.from(completeEmptyBuckets);
657
- for (const [index, bucket] of emptyBuckets.entries()) {
658
- const startOpId = bucketMap.get(bucket);
659
- if (startOpId == null) {
660
- throw new ServiceAssertionError(`data for unexpected bucket: ${bucket}`);
661
- }
662
- const isLastChunkOfBatch = data.length == 0 && index == emptyBuckets.length - 1;
663
- yield {
664
- chunkData: {
665
- bucket,
666
- after: internalToExternalOpId(startOpId),
667
- has_more: isLastChunkOfBatch && batchHasMore,
668
- data: [],
669
- next_after: internalToExternalOpId(end)
670
- },
671
- targetOp: null
672
- };
673
- }
674
-
675
- if (data.length == 0) {
676
- if (batchHasMore) {
677
- // The remaining documents are read in the next round, after the caller has
678
- // advanced the positions of the empty buckets above.
679
- return;
680
- }
681
- 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;
682
645
  }
683
646
 
684
- 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 });
685
651
 
686
652
  let currentChunkSizeBytes = 0;
687
653
  let currentChunk: utils.SyncBucketData | null = null;
688
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
+ }
689
665
 
690
- for (const { row, docIndex, isLastOpInDocument } of walkDocumentOps(data, documentOpCounts, documentSizes)) {
691
- const bucket = row.bucketKey.bucket;
692
-
693
- 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
+ ) {
694
673
  let start: ProtocolOpId | undefined = undefined;
695
674
  if (currentChunk != null) {
696
675
  if (currentChunk.bucket == bucket) {
@@ -706,12 +685,9 @@ export async function* getBucketDataBatchV3(
706
685
  }
707
686
 
708
687
  if (start == null) {
709
- const startOpId = bucketMap.get(bucket);
710
- if (startOpId == null) {
711
- throw new Error(`data for unexpected bucket: ${bucket}`);
712
- }
713
- start = internalToExternalOpId(startOpId);
688
+ start = internalToExternalOpId(bucketStart);
714
689
  }
690
+
715
691
  currentChunk = {
716
692
  bucket,
717
693
  after: start,
@@ -721,27 +697,47 @@ export async function* getBucketDataBatchV3(
721
697
  };
722
698
  }
723
699
 
724
- const entry = mapOpEntry(row);
725
- if (row.target_op != null && (targetOp == null || row.target_op > targetOp)) {
726
- targetOp = row.target_op;
727
- }
728
-
729
- currentChunk.data.push(entry);
730
- currentChunk.next_after = entry.op_id;
731
-
732
- if (isLastOpInDocument) {
733
- currentChunkSizeBytes += documentSizes[docIndex];
734
- }
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;
735
705
  }
736
706
 
737
707
  if (currentChunk != null) {
738
708
  const yieldChunk = currentChunk;
739
- 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;
740
711
  yield { chunkData: yieldChunk, targetOp };
741
712
  }
742
713
 
743
- if (batchHasMore || remainingLimit <= 0) {
744
- 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.
745
741
  }
746
742
  }
747
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
  }