@powersync/service-module-mongodb-storage 0.20.0 → 0.22.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 (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -1,10 +1,19 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { addChecksums, storage, utils } from '@powersync/service-core';
1
+ import { mongo, MONGO_OPERATION_TIMEOUT_MS } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import {
4
+ addChecksums,
5
+ CompactInitialReplicationResults,
6
+ InternalOpId,
7
+ isPartialChecksum,
8
+ storage,
9
+ SyncRuleState,
10
+ utils
11
+ } from '@powersync/service-core';
4
12
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
13
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
6
14
  import { BucketStateDocumentBase, LEGACY_BUCKET_DATA_DEFINITION_ID } from '../models.js';
7
- import { CurrentBucketState, DirtyBucket, MongoCompactor } from '../MongoCompactor.js';
15
+ import { MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
16
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
8
17
  import { cacheKey } from '../OperationBatch.js';
9
18
  import { BucketDataDocumentV1, BucketStateDocumentV1 } from './models.js';
10
19
  import type { MongoSyncBucketStorageV1 } from './MongoSyncBucketStorageV1.js';
@@ -13,12 +22,79 @@ import { VersionedPowerSyncMongoV1 } from './VersionedPowerSyncMongoV1.js';
13
22
 
14
23
  type CompactClearProperties = 'op' | 'checksum' | 'target_op';
15
24
 
25
+ const DEFAULT_MIN_BUCKET_CHANGES = 10;
26
+ const DEFAULT_MIN_CHANGE_RATIO = 0.1;
27
+ const DIRTY_BUCKET_SCAN_BATCH_SIZE = 2_000;
28
+
29
+ interface CurrentBucketState {
30
+ bucket: string;
31
+ definitionId: BucketDefinitionId;
32
+ seen: Map<string, InternalOpId>;
33
+ trackingSize: number;
34
+ lastNotPut: InternalOpId | null;
35
+ opsSincePut: number;
36
+ checksum: number;
37
+ opCount: number;
38
+ opBytes: number;
39
+ }
40
+
41
+ interface DirtyBucket {
42
+ bucket: string;
43
+ definitionId: BucketDefinitionId | null;
44
+ estimatedCount: number;
45
+ dirtyRatio?: number;
46
+ }
47
+
16
48
  export class MongoCompactorV1 extends MongoCompactor {
17
49
  // Override types to the more specific ones
18
50
  declare protected readonly db: VersionedPowerSyncMongoV1;
19
51
  declare protected readonly storage: MongoSyncBucketStorageV1;
20
52
 
21
53
  private updates: mongo.AnyBulkWriteOperation<BucketDataDocumentV1>[] = [];
54
+ private bucketStateUpdates: mongo.AnyBulkWriteOperation<BucketStateDocumentBase>[] = [];
55
+ private readonly minBucketChanges: number;
56
+ private readonly minChangeRatio: number;
57
+ private readonly maxOpId: bigint;
58
+
59
+ constructor(
60
+ bucketStorage: MongoSyncBucketStorageV1,
61
+ db: VersionedPowerSyncMongoV1,
62
+ private options: MongoCompactOptions
63
+ ) {
64
+ super(bucketStorage, db, options);
65
+ this.minBucketChanges = options.minBucketChanges ?? DEFAULT_MIN_BUCKET_CHANGES;
66
+ this.minChangeRatio = options.minChangeRatio ?? DEFAULT_MIN_CHANGE_RATIO;
67
+ this.maxOpId = options.maxOpId ?? 0n;
68
+ }
69
+
70
+ /**
71
+ * Compact buckets by converting operations into MOVE and/or CLEAR operations.
72
+ *
73
+ * See /docs/storage/compacting-operations.md for details.
74
+ */
75
+ override async compact(): Promise<number> {
76
+ if (this.options?.incrementalOnly) {
77
+ // Not supported for V1
78
+ this.logger.info('Incremental compacting is not supported on MongoDB storage V1/V2');
79
+ return 0;
80
+ } else if (this.storage.replicationStream.state != SyncRuleState.ACTIVE) {
81
+ this.logger.info(`Skipping compacting of replication stream in ${this.storage.replicationStream.state} state.`);
82
+ return 0;
83
+ }
84
+ await this.deleteOldCheckpointRequests();
85
+
86
+ if (this.buckets) {
87
+ for (const bucket of this.buckets) {
88
+ // We can make this more efficient later on by iterating through the buckets in a single query.
89
+ // That makes batching more tricky, so we leave for later.
90
+ await this.compactSingleBucketRetried(bucket);
91
+ }
92
+ } else {
93
+ await this.compactDirtyBuckets();
94
+ }
95
+
96
+ return this.compactedBucketCount;
97
+ }
22
98
 
23
99
  public async *dirtyBucketBatches(options: {
24
100
  minBucketChanges: number;
@@ -34,8 +110,7 @@ export class MongoCompactorV1 extends MongoCompactor {
34
110
  this.db.bucketStateV1,
35
111
  { g: this.group_id, b: new mongo.MinKey() as any },
36
112
  { g: this.group_id, b: new mongo.MaxKey() as any },
37
- options,
38
- () => null
113
+ options
39
114
  );
40
115
  }
41
116
 
@@ -45,20 +120,232 @@ export class MongoCompactorV1 extends MongoCompactor {
45
120
  }
46
121
  // Unlike dirtyBucketBatches, this path is resumable after restart because populateChecksums resets
47
122
  // estimate_since_compact as it progresses.
48
- return this.dirtyBucketBatchForChecksumsForCollection(
49
- this.db.bucketStateV1,
50
- {
51
- '_id.g': this.group_id,
52
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
53
- },
54
- () => null
55
- );
123
+ return this.dirtyBucketBatchForChecksumsForCollection({
124
+ '_id.g': this.group_id,
125
+ 'estimate_since_compact.count': { $gte: options.minBucketChanges }
126
+ });
127
+ }
128
+
129
+ private async *dirtyBucketBatchesForCollection<TBucketState extends BucketStateDocumentBase>(
130
+ collection: mongo.Collection<TBucketState>,
131
+ lastId: TBucketState['_id'],
132
+ maxId: TBucketState['_id'],
133
+ options: { minBucketChanges: number; minChangeRatio: number }
134
+ ): AsyncGenerator<DirtyBucket[]> {
135
+ // Paginate through the bucket state collection using cursor-based scanning.
136
+ while (true) {
137
+ // To avoid timeouts from too many buckets not meeting the minimum-change
138
+ // criteria, scan a fixed batch and only return matching buckets.
139
+ const [result] = await collection
140
+ .aggregate<{ buckets: TBucketState[]; cursor: Pick<TBucketState, '_id'>[] }>(
141
+ [
142
+ { $match: { _id: { $gt: lastId, $lt: maxId } } },
143
+ { $sort: { _id: 1 } },
144
+ // Scan a fixed number of docs each query so sparse matches don't block progress.
145
+ { $limit: DIRTY_BUCKET_SCAN_BATCH_SIZE },
146
+ {
147
+ $facet: {
148
+ buckets: [
149
+ { $match: { 'estimate_since_compact.count': { $gte: options.minBucketChanges } } },
150
+ { $project: { _id: 1, estimate_since_compact: 1, compacted_state: 1 } }
151
+ ],
152
+ cursor: [{ $sort: { _id: -1 } }, { $limit: 1 }, { $project: { _id: 1 } }]
153
+ }
154
+ }
155
+ ],
156
+ { maxTimeMS: MONGO_OPERATION_TIMEOUT_MS }
157
+ )
158
+ .toArray();
159
+
160
+ const cursor = result?.cursor?.[0];
161
+ if (cursor == null) {
162
+ break;
163
+ }
164
+ lastId = cursor._id;
165
+
166
+ const dirtyBuckets = (result?.buckets ?? []).map((bucketState) => {
167
+ // BigInt precision is not needed here since this is only an estimate.
168
+ const updatedCount = bucketState.estimate_since_compact?.count ?? 0;
169
+ const totalCount = (bucketState.compacted_state?.count ?? 0) + updatedCount;
170
+ const updatedBytes = Number(bucketState.estimate_since_compact?.bytes ?? 0);
171
+ const totalBytes = Number(bucketState.compacted_state?.bytes ?? 0) + updatedBytes;
172
+ return {
173
+ bucket: bucketState._id.b,
174
+ definitionId: null,
175
+ estimatedCount: totalCount,
176
+ dirtyRatio: Math.max(
177
+ totalCount > 0 ? updatedCount / totalCount : 0,
178
+ totalBytes > 0 ? updatedBytes / totalBytes : 0
179
+ )
180
+ };
181
+ });
182
+
183
+ yield dirtyBuckets.filter(
184
+ (bucket) => bucket.estimatedCount >= options.minBucketChanges && bucket.dirtyRatio >= options.minChangeRatio
185
+ );
186
+ }
187
+ }
188
+
189
+ private async dirtyBucketBatchForChecksumsForCollection(
190
+ filter: mongo.Filter<BucketStateDocumentV1>
191
+ ): Promise<DirtyBucket[]> {
192
+ const dirtyBuckets = await this.db.bucketStateV1
193
+ .find(filter, {
194
+ projection: { _id: 1, estimate_since_compact: 1, compacted_state: 1 },
195
+ sort: { 'estimate_since_compact.count': -1 },
196
+ limit: 200,
197
+ maxTimeMS: MONGO_OPERATION_TIMEOUT_MS
198
+ })
199
+ .toArray();
200
+
201
+ return dirtyBuckets.map((bucket) => ({
202
+ bucket: bucket._id.b,
203
+ definitionId: null,
204
+ estimatedCount: Number(bucket.estimate_since_compact!.count) + Number(bucket.compacted_state?.count ?? 0)
205
+ }));
206
+ }
207
+
208
+ private async compactSingleBucketRetried(bucket: string, _definitionId: BucketDefinitionId | null = null) {
209
+ await this.retryCompaction(bucket, () => this.compactSingleBucket(bucket));
210
+ this.compactedBucketCount++;
211
+ }
212
+
213
+ private async compactDirtyBuckets() {
214
+ for await (const buckets of this.dirtyBucketBatches({
215
+ minBucketChanges: this.minBucketChanges,
216
+ minChangeRatio: this.minChangeRatio
217
+ })) {
218
+ this.signal?.throwIfAborted();
219
+ for (const { bucket, definitionId } of buckets) {
220
+ await this.compactSingleBucketRetried(bucket, definitionId);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Subset of compact, only populating checksums where relevant.
227
+ */
228
+ async populateChecksums(options: { minBucketChanges: number }): Promise<CompactInitialReplicationResults> {
229
+ let count = 0;
230
+ // Paginate through dirty buckets in batches until no more buckets meet the criteria.
231
+ while (true) {
232
+ this.signal?.throwIfAborted();
233
+ const buckets = await this.dirtyBucketBatchForChecksums(options);
234
+ if (buckets.length == 0) {
235
+ break;
236
+ }
237
+ this.signal?.throwIfAborted();
238
+
239
+ const start = Date.now();
240
+ // Filter batch by estimated bucket size, to reduce possibility of timeouts.
241
+ const checkBuckets: typeof buckets = [];
242
+ let totalCountEstimate = 0;
243
+ for (const bucket of buckets) {
244
+ checkBuckets.push(bucket);
245
+ totalCountEstimate += bucket.estimatedCount;
246
+ if (totalCountEstimate > 50_000) {
247
+ break;
248
+ }
249
+ }
250
+ this.logger.info(
251
+ `Calculating checksums for batch of ${buckets.length} buckets, estimated count of ${totalCountEstimate}`
252
+ );
253
+ await this.updateChecksumsBatch(checkBuckets);
254
+ this.logger.info(`Updated checksums for batch of ${checkBuckets.length} buckets in ${Date.now() - start}ms`);
255
+ count += checkBuckets.length;
256
+ }
257
+ return { buckets: count };
258
+ }
259
+
260
+ private collectBucketStateUpdate(
261
+ state: CurrentBucketState,
262
+ compactedOpId: bigint
263
+ ): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
264
+ if (state.opCount < 0) {
265
+ throw new ServiceAssertionError(
266
+ `Invalid opCount: ${state.opCount} checksum ${state.checksum} opsSincePut: ${state.opsSincePut} maxOpId: ${this.maxOpId}`
267
+ );
268
+ }
269
+ return {
270
+ updateOne: {
271
+ filter: this.bucketStateFilter(state.bucket),
272
+ update: {
273
+ $set: {
274
+ compacted_state: {
275
+ op_id: compactedOpId,
276
+ count: state.opCount,
277
+ checksum: BigInt(state.checksum),
278
+ bytes: state.opBytes
279
+ },
280
+ estimate_since_compact: {
281
+ // There could have been a whole bunch of new operations added to the bucket while compacting,
282
+ // which we don't currently cater for. We could potentially query for that, but that adds overhead.
283
+ count: 0,
284
+ bytes: 0
285
+ }
286
+ }
287
+ } satisfies mongo.UpdateFilter<BucketStateDocumentBase>,
288
+ // We generally expect this to have been created before.
289
+ // We don't create new ones here, to avoid issues with the unique index on bucket_updates.
290
+ upsert: false
291
+ }
292
+ };
293
+ }
294
+
295
+ private updateBucketChecksums(state: CurrentBucketState, compactedOpId: bigint) {
296
+ this.bucketStateUpdates.push(this.collectBucketStateUpdate(state, compactedOpId));
297
+ }
298
+
299
+ private async flushBucketStateUpdates() {
300
+ const writes = this.db.createWriteBatch(undefined, { ordered: false });
301
+ this.writeBucketStateUpdates(writes);
302
+ await writes.execute();
303
+ this.bucketStateUpdates = [];
56
304
  }
57
305
 
58
- protected async writeBucketStateUpdates(): Promise<void> {
59
- await this.db.bucketStateV1.bulkWrite(
60
- this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[],
61
- { ordered: false }
306
+ private async updateChecksumsBatch(buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]) {
307
+ const checksums = await this.computeChecksumsForBuckets(buckets);
308
+ const definitionIdByBucket = new Map(buckets.map((bucket) => [bucket.bucket, bucket.definitionId]));
309
+
310
+ for (const bucketChecksum of checksums.values()) {
311
+ if (isPartialChecksum(bucketChecksum)) {
312
+ // Should never happen since we don't specify `start`.
313
+ throw new ServiceAssertionError(`Full checksum expected, got ${JSON.stringify(bucketChecksum)}`);
314
+ }
315
+
316
+ this.bucketStateUpdates.push({
317
+ updateOne: {
318
+ filter: this.bucketStateFilter(bucketChecksum.bucket),
319
+ update: {
320
+ $set: {
321
+ compacted_state: {
322
+ op_id: this.maxOpId,
323
+ count: bucketChecksum.count,
324
+ checksum: BigInt(bucketChecksum.checksum),
325
+ bytes: null
326
+ },
327
+ estimate_since_compact: {
328
+ count: 0,
329
+ bytes: 0
330
+ }
331
+ }
332
+ } satisfies mongo.UpdateFilter<BucketStateDocumentBase>,
333
+ // We don't create new ones here - it gets tricky to get the last_op right with the unique index on
334
+ // bucket_updates.
335
+ upsert: false
336
+ }
337
+ });
338
+ }
339
+
340
+ await this.flushBucketStateUpdates();
341
+ }
342
+
343
+ protected writeBucketStateUpdates(writes: MongoWriteBatch): void {
344
+ if (this.bucketStateUpdates.length === 0) return;
345
+ this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
346
+ writes.bulkWriteUnordered(
347
+ this.db.bucketStateV1,
348
+ this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[]
62
349
  );
63
350
  }
64
351
 
@@ -74,10 +361,7 @@ export class MongoCompactorV1 extends MongoCompactor {
74
361
  );
75
362
  }
76
363
 
77
- protected bucketStateFilter(
78
- bucket: string,
79
- _definitionId: BucketDefinitionId | null
80
- ): mongo.Filter<BucketStateDocumentBase> {
364
+ private bucketStateFilter(bucket: string): mongo.Filter<BucketStateDocumentBase> {
81
365
  return {
82
366
  _id: {
83
367
  g: this.group_id,
@@ -95,8 +379,9 @@ export class MongoCompactorV1 extends MongoCompactor {
95
379
  }
96
380
 
97
381
  protected async compactSingleBucket(bucket: string) {
98
- // Do not carry queued writes from a failed attempt into the rescan
382
+ // Do not carry queued writes from a failed attempt into the rescan.
99
383
  this.updates = [];
384
+ this.bucketStateUpdates = [];
100
385
 
101
386
  const idLimitBytes = this.idLimitBytes;
102
387
  const bucketContext = this.getBucketDataContext(bucket);
@@ -200,6 +485,7 @@ export class MongoCompactorV1 extends MongoCompactor {
200
485
  $unset: {
201
486
  source_table: 1,
202
487
  source_key: 1,
488
+ subkey: 1,
203
489
  table: 1,
204
490
  row_id: 1,
205
491
  data: 1
@@ -257,17 +543,19 @@ export class MongoCompactorV1 extends MongoCompactor {
257
543
  }
258
544
 
259
545
  private async flush(bucketContext: SingleBucketStoreV1) {
546
+ // Persist data changes before advertising the resulting bucket state.
547
+ const writes = this.db.createWriteBatch(undefined, { ordered: true });
260
548
  if (this.updates.length > 0) {
261
549
  this.logger.info(`Compacting ${this.updates.length} ops`);
262
- await bucketContext.collection.bulkWrite(this.updates, {
263
- // Order is not important. Since checksums are not affected, these operations can happen in any order,
264
- // and it's fine if the operations are partially applied. Each individual operation is atomic.
265
- ordered: false
266
- });
267
- this.updates = [];
550
+ // Order is not important. Since checksums are not affected, these operations can happen in any order,
551
+ // and it's fine if the operations are partially applied. Each individual operation is atomic.
552
+ writes.bulkWriteUnordered(bucketContext.collection, this.updates);
268
553
  }
269
554
 
270
- await this.flushBucketStateUpdates();
555
+ this.writeBucketStateUpdates(writes);
556
+ await writes.execute();
557
+ this.updates = [];
558
+ this.bucketStateUpdates = [];
271
559
  }
272
560
 
273
561
  /**
@@ -338,15 +626,13 @@ export class MongoCompactorV1 extends MongoCompactor {
338
626
  }
339
627
 
340
628
  this.logger.info(`Flushing CLEAR for ${numberOfOpsToClear} ops at ${lastOp?.o}`);
341
- await bucketContext.collection.deleteMany(
342
- {
343
- _id: {
344
- $gte: bucketContext.minId,
345
- $lte: bucketContext.docId(lastOp!.o)
346
- }
347
- },
348
- { session }
349
- );
629
+ const writes = this.db.createWriteBatch(session, { ordered: true });
630
+ writes.deleteMany(bucketContext.collection, {
631
+ _id: {
632
+ $gte: bucketContext.minId,
633
+ $lte: bucketContext.docId(lastOp!.o)
634
+ }
635
+ });
350
636
 
351
637
  const op = bucketContext.toPersistedDocument({
352
638
  o: lastOp!.o,
@@ -355,7 +641,8 @@ export class MongoCompactorV1 extends MongoCompactor {
355
641
  data: null,
356
642
  target_op: targetOp
357
643
  });
358
- await bucketContext.collection.insertOne(op, { session });
644
+ writes.insertOne(bucketContext.collection, op);
645
+ await writes.execute();
359
646
 
360
647
  opCountDiff = -numberOfOpsToClear + 1;
361
648
  },
@@ -1,4 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { bson, InternalOpId } from '@powersync/service-core';
2
3
  import { MongoParameterCompactor } from '../MongoParameterCompactor.js';
3
4
  import { VersionedPowerSyncMongoV1 } from './VersionedPowerSyncMongoV1.js';
4
5
 
@@ -9,18 +10,33 @@ export class MongoParameterCompactorV1 extends MongoParameterCompactor {
9
10
  return [this.db.parameterIndexV1 as unknown as mongo.Collection<mongo.Document>];
10
11
  }
11
12
 
12
- protected collectionFilter(): mongo.Document {
13
- return {
14
- 'key.g': this.group_id
15
- };
13
+ // The shared V1 collection is scanned using only its default `_id` index. Filter the stream in
14
+ // code so compaction does not require an index on `key.g`.
15
+ protected override shouldCompactDocument(doc: { key: mongo.Document }): boolean {
16
+ return doc.key.g === this.replicationStreamId;
16
17
  }
17
18
 
18
- protected deleteFilter(doc: mongo.Document): mongo.Document {
19
+ /**
20
+ * Uses the legacy `{ 'key.g': 1, lookup: 1, _id: 1 }` index to narrow the stream, lookup and
21
+ * operation-id range. `key` is not part of that index at all, so it is a residual predicate applied
22
+ * to every document the range scan returns.
23
+ *
24
+ * That scan may therefore have to filter through many keys for the same lookup, but the cost is
25
+ * amortized: a single scan covers up to 1000 keys, and identities seen again in a later batch skip
26
+ * the scan entirely - they are deleted by `_id`.
27
+ *
28
+ * The V3 storage format uses an index more suitable for this.
29
+ */
30
+ protected leadingHistoryDeleteFilter(
31
+ lookup: bson.Binary,
32
+ keys: mongo.Document[],
33
+ before: InternalOpId
34
+ ): mongo.Document {
19
35
  return {
20
- 'key.g': doc.key.g as number,
21
- lookup: doc.lookup,
22
- _id: { $lte: doc._id },
23
- key: doc.key
36
+ 'key.g': this.replicationStreamId,
37
+ lookup,
38
+ key: { $in: keys },
39
+ _id: { $lt: before }
24
40
  };
25
41
  }
26
42
  }
@@ -3,6 +3,8 @@ import { mongo } from '@powersync/lib-service-mongodb';
3
3
  import { ServiceAssertionError } from '@powersync/lib-services-framework';
4
4
  import {
5
5
  CheckpointChanges,
6
+ CompactInitialReplicationOptions,
7
+ CompactInitialReplicationResults,
6
8
  deserializeParameterLookup,
7
9
  GetCheckpointChangesOptions,
8
10
  InternalOpId,
@@ -26,13 +28,20 @@ import {
26
28
  setSessionSnapshotTime
27
29
  } from '../../../utils/util.js';
28
30
  import { MongoBucketStorage } from '../../MongoBucketStorage.js';
29
- import { MongoSyncBucketStorageCheckpoint } from '../common/MongoSyncBucketStorageCheckpoint.js';
31
+ import {
32
+ MongoGetCheckpointChangesOptions,
33
+ MongoSyncBucketStorageCheckpoint
34
+ } from '../common/MongoSyncBucketStorageCheckpoint.js';
30
35
  import { SourceKey } from '../models.js';
31
36
  import { MongoChecksums } from '../MongoChecksums.js';
32
- import { MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
37
+ import { MongoCompactOptions } from '../MongoCompactor.js';
33
38
  import { MongoParameterCompactor } from '../MongoParameterCompactor.js';
34
39
  import { MongoPersistedReplicationStream } from '../MongoPersistedReplicationStream.js';
35
- import { MongoSyncBucketStorage, MongoSyncBucketStorageOptions } from '../MongoSyncBucketStorage.js';
40
+ import {
41
+ MongoCheckpointState,
42
+ MongoSyncBucketStorage,
43
+ MongoSyncBucketStorageOptions
44
+ } from '../MongoSyncBucketStorage.js';
36
45
  import {
37
46
  BucketDataDocumentV1,
38
47
  BucketDataKeyV1,
@@ -100,14 +109,20 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
100
109
  });
101
110
  }
102
111
 
103
- protected async fetchCheckpointState(
104
- session: mongo.ClientSession
105
- ): Promise<{ checkpoint: bigint; lsn: string | null } | null> {
112
+ protected async fetchCheckpointState(session: mongo.ClientSession): Promise<MongoCheckpointState | null> {
106
113
  const doc = (await this.db.sync_rules.findOne(
107
114
  { _id: this.replicationStreamId },
108
115
  {
109
116
  session,
110
- projection: { _id: 1, state: 1, last_checkpoint: 1, last_checkpoint_lsn: 1, snapshot_done: 1 }
117
+ projection: {
118
+ _id: 1,
119
+ state: 1,
120
+ last_checkpoint: 1,
121
+ last_checkpoint_lsn: 1,
122
+ snapshot_done: 1,
123
+ // Must be read in the same snapshot as the checkpoint.
124
+ 'parameter_compaction.checkpoint_changes_invalid_before': 1
125
+ }
111
126
  }
112
127
  )) as SyncRuleDocumentV1;
113
128
  if (!doc?.snapshot_done || ![storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED].includes(doc.state)) {
@@ -115,7 +130,9 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
115
130
  }
116
131
  return {
117
132
  checkpoint: doc.last_checkpoint ?? 0n,
118
- lsn: doc.last_checkpoint_lsn ?? null
133
+ lsn: doc.last_checkpoint_lsn ?? null,
134
+ // Defaults to 0n for streams that have never been compacted.
135
+ parameterChangesInvalidBefore: doc.parameter_compaction?.checkpoint_changes_invalid_before ?? 0n
119
136
  };
120
137
  }
121
138
 
@@ -171,7 +188,8 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
171
188
  no_checkpoint_before: null
172
189
  },
173
190
  $unset: {
174
- snapshot_lsn: 1
191
+ snapshot_lsn: 1,
192
+ parameter_compaction: 1
175
193
  }
176
194
  },
177
195
  { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
@@ -186,10 +204,31 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
186
204
  });
187
205
  }
188
206
 
189
- createMongoCompactor(options: MongoCompactOptions): MongoCompactor {
207
+ createMongoCompactor(options: MongoCompactOptions): MongoCompactorV1 {
190
208
  return new MongoCompactorV1(this, this.db, options);
191
209
  }
192
210
 
211
+ async compactInitialReplication(
212
+ options: CompactInitialReplicationOptions
213
+ ): Promise<CompactInitialReplicationResults> {
214
+ this.logger.info(`Compacting after initial replication...`);
215
+ const start = Date.now();
216
+ const maxOpId = options.maxOpId ?? (await this.fetchPersistedOpHead()) ?? undefined;
217
+ const compactor = this.createMongoCompactor({
218
+ ...options,
219
+ maxOpId,
220
+ memoryLimitMB: 0,
221
+ logger: this.logger
222
+ });
223
+
224
+ const result = await compactor.populateChecksums({
225
+ minBucketChanges: options.minBucketChanges ?? 10
226
+ });
227
+ const duration = Date.now() - start;
228
+ this.logger.info(`Compacted after initial replication in ${(duration / 1000).toFixed(1)}s`);
229
+ return result;
230
+ }
231
+
193
232
  protected createMongoParameterCompactor(
194
233
  checkpoint: InternalOpId,
195
234
  options: storage.CompactOptions
@@ -303,7 +342,7 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
303
342
  }
304
343
 
305
344
  protected getParameterBucketChangesImpl(
306
- options: GetCheckpointChangesOptions
345
+ options: MongoGetCheckpointChangesOptions
307
346
  ): Promise<Pick<CheckpointChanges, 'updatedParameterLookups' | 'invalidateParameterBuckets'>> {
308
347
  return getParameterBucketChangesV1(this.versionContext, options);
309
348
  }
@@ -548,27 +587,48 @@ export async function getDataBucketChangesV1(
548
587
  };
549
588
  }
550
589
 
590
+ /**
591
+ * Query the parameter entries changed between the two checkpoints, to determine which parameter
592
+ * lookups need to be re-evaluated.
593
+ *
594
+ * This runs at the next checkpoint's snapshot, so it still sees entries that parameter compaction
595
+ * deleted after that snapshot. Compaction that deleted entries before the snapshot is covered by
596
+ * the invalidation fence, checked before we get here.
597
+ */
551
598
  export async function getParameterBucketChangesV1(
552
599
  ctx: MongoSyncBucketStorageContextV1,
553
- options: GetCheckpointChangesOptions
600
+ options: MongoGetCheckpointChangesOptions
554
601
  ): Promise<Pick<CheckpointChanges, 'updatedParameterLookups' | 'invalidateParameterBuckets'>> {
555
602
  const limit = 1000;
556
- const parameterUpdates = await ctx.db.parameterIndexV1
557
- .find(
558
- {
559
- _id: { $gt: options.lastCheckpoint.checkpoint, $lte: options.nextCheckpoint.checkpoint },
560
- 'key.g': ctx.replicationStreamId
561
- },
562
- {
563
- projection: {
564
- lookup: 1
603
+ const parameterUpdates = await ctx.db.client.withSession({ snapshot: true }, async (session) => {
604
+ setSessionSnapshotTime(session, options.nextCheckpoint.snapshotTime);
605
+ return await ctx.db.parameterIndexV1
606
+ .find(
607
+ {
608
+ _id: { $gt: options.lastCheckpoint.checkpoint, $lte: options.nextCheckpoint.checkpoint },
609
+ 'key.g': ctx.replicationStreamId
565
610
  },
566
- limit: limit + 1,
567
- batchSize: limit + 2,
568
- singleBatch: true
569
- }
570
- )
571
- .toArray();
611
+ {
612
+ session,
613
+ readConcern: 'snapshot',
614
+ projection: {
615
+ lookup: 1
616
+ },
617
+ limit: limit + 1,
618
+ batchSize: limit + 2,
619
+ singleBatch: true
620
+ }
621
+ )
622
+ .toArray()
623
+ .catch((e) => {
624
+ // Includes the case where the checkpoint snapshot has expired. Degrading to
625
+ // invalidateParameterBuckets would be safe in itself - it reads nothing - but it gains
626
+ // nothing: the caller responds to that by re-evaluating the parameter queries at this
627
+ // same snapshot, which fails too. The checkpoint has to be refetched instead, which is
628
+ // what the existing sync retry behavior does.
629
+ throw lib_mongo.mapQueryError(e, 'while querying parameter changes');
630
+ });
631
+ });
572
632
  const invalidateParameterUpdates = parameterUpdates.length > limit;
573
633
 
574
634
  return {