@powersync/service-module-mongodb-storage 0.19.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  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 +3 -1
  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/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +35 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +6 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +80 -84
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +120 -39
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +89 -5
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  106. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +38 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +107 -96
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +159 -42
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +96 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +365 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -1,10 +1,18 @@
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';
8
16
  import { cacheKey } from '../OperationBatch.js';
9
17
  import { BucketDataDocumentV1, BucketStateDocumentV1 } from './models.js';
10
18
  import type { MongoSyncBucketStorageV1 } from './MongoSyncBucketStorageV1.js';
@@ -13,12 +21,79 @@ import { VersionedPowerSyncMongoV1 } from './VersionedPowerSyncMongoV1.js';
13
21
 
14
22
  type CompactClearProperties = 'op' | 'checksum' | 'target_op';
15
23
 
24
+ const DEFAULT_MIN_BUCKET_CHANGES = 10;
25
+ const DEFAULT_MIN_CHANGE_RATIO = 0.1;
26
+ const DIRTY_BUCKET_SCAN_BATCH_SIZE = 2_000;
27
+
28
+ interface CurrentBucketState {
29
+ bucket: string;
30
+ definitionId: BucketDefinitionId;
31
+ seen: Map<string, InternalOpId>;
32
+ trackingSize: number;
33
+ lastNotPut: InternalOpId | null;
34
+ opsSincePut: number;
35
+ checksum: number;
36
+ opCount: number;
37
+ opBytes: number;
38
+ }
39
+
40
+ interface DirtyBucket {
41
+ bucket: string;
42
+ definitionId: BucketDefinitionId | null;
43
+ estimatedCount: number;
44
+ dirtyRatio?: number;
45
+ }
46
+
16
47
  export class MongoCompactorV1 extends MongoCompactor {
17
48
  // Override types to the more specific ones
18
49
  declare protected readonly db: VersionedPowerSyncMongoV1;
19
50
  declare protected readonly storage: MongoSyncBucketStorageV1;
20
51
 
21
52
  private updates: mongo.AnyBulkWriteOperation<BucketDataDocumentV1>[] = [];
53
+ private bucketStateUpdates: mongo.AnyBulkWriteOperation<BucketStateDocumentBase>[] = [];
54
+ private readonly minBucketChanges: number;
55
+ private readonly minChangeRatio: number;
56
+ private readonly maxOpId: bigint;
57
+
58
+ constructor(
59
+ bucketStorage: MongoSyncBucketStorageV1,
60
+ db: VersionedPowerSyncMongoV1,
61
+ private options: MongoCompactOptions
62
+ ) {
63
+ super(bucketStorage, db, options);
64
+ this.minBucketChanges = options.minBucketChanges ?? DEFAULT_MIN_BUCKET_CHANGES;
65
+ this.minChangeRatio = options.minChangeRatio ?? DEFAULT_MIN_CHANGE_RATIO;
66
+ this.maxOpId = options.maxOpId ?? 0n;
67
+ }
68
+
69
+ /**
70
+ * Compact buckets by converting operations into MOVE and/or CLEAR operations.
71
+ *
72
+ * See /docs/storage/compacting-operations.md for details.
73
+ */
74
+ override async compact(): Promise<number> {
75
+ if (this.options?.incrementalOnly) {
76
+ // Not supported for V1
77
+ this.logger.info('Incremental compacting is not supported on MongoDB storage V1/V2');
78
+ return 0;
79
+ } else if (this.storage.replicationStream.state != SyncRuleState.ACTIVE) {
80
+ this.logger.info(`Skipping compacting of replication stream in ${this.storage.replicationStream.state} state.`);
81
+ return 0;
82
+ }
83
+ await this.deleteOldCheckpointRequests();
84
+
85
+ if (this.buckets) {
86
+ for (const bucket of this.buckets) {
87
+ // We can make this more efficient later on by iterating through the buckets in a single query.
88
+ // That makes batching more tricky, so we leave for later.
89
+ await this.compactSingleBucketRetried(bucket);
90
+ }
91
+ } else {
92
+ await this.compactDirtyBuckets();
93
+ }
94
+
95
+ return this.compactedBucketCount;
96
+ }
22
97
 
23
98
  public async *dirtyBucketBatches(options: {
24
99
  minBucketChanges: number;
@@ -34,8 +109,7 @@ export class MongoCompactorV1 extends MongoCompactor {
34
109
  this.db.bucketStateV1,
35
110
  { g: this.group_id, b: new mongo.MinKey() as any },
36
111
  { g: this.group_id, b: new mongo.MaxKey() as any },
37
- options,
38
- () => null
112
+ options
39
113
  );
40
114
  }
41
115
 
@@ -45,14 +119,225 @@ export class MongoCompactorV1 extends MongoCompactor {
45
119
  }
46
120
  // Unlike dirtyBucketBatches, this path is resumable after restart because populateChecksums resets
47
121
  // 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
- );
122
+ return this.dirtyBucketBatchForChecksumsForCollection({
123
+ '_id.g': this.group_id,
124
+ 'estimate_since_compact.count': { $gte: options.minBucketChanges }
125
+ });
126
+ }
127
+
128
+ private async *dirtyBucketBatchesForCollection<TBucketState extends BucketStateDocumentBase>(
129
+ collection: mongo.Collection<TBucketState>,
130
+ lastId: TBucketState['_id'],
131
+ maxId: TBucketState['_id'],
132
+ options: { minBucketChanges: number; minChangeRatio: number }
133
+ ): AsyncGenerator<DirtyBucket[]> {
134
+ // Paginate through the bucket state collection using cursor-based scanning.
135
+ while (true) {
136
+ // To avoid timeouts from too many buckets not meeting the minimum-change
137
+ // criteria, scan a fixed batch and only return matching buckets.
138
+ const [result] = await collection
139
+ .aggregate<{ buckets: TBucketState[]; cursor: Pick<TBucketState, '_id'>[] }>(
140
+ [
141
+ { $match: { _id: { $gt: lastId, $lt: maxId } } },
142
+ { $sort: { _id: 1 } },
143
+ // Scan a fixed number of docs each query so sparse matches don't block progress.
144
+ { $limit: DIRTY_BUCKET_SCAN_BATCH_SIZE },
145
+ {
146
+ $facet: {
147
+ buckets: [
148
+ { $match: { 'estimate_since_compact.count': { $gte: options.minBucketChanges } } },
149
+ { $project: { _id: 1, estimate_since_compact: 1, compacted_state: 1 } }
150
+ ],
151
+ cursor: [{ $sort: { _id: -1 } }, { $limit: 1 }, { $project: { _id: 1 } }]
152
+ }
153
+ }
154
+ ],
155
+ { maxTimeMS: MONGO_OPERATION_TIMEOUT_MS }
156
+ )
157
+ .toArray();
158
+
159
+ const cursor = result?.cursor?.[0];
160
+ if (cursor == null) {
161
+ break;
162
+ }
163
+ lastId = cursor._id;
164
+
165
+ const dirtyBuckets = (result?.buckets ?? []).map((bucketState) => {
166
+ // BigInt precision is not needed here since this is only an estimate.
167
+ const updatedCount = bucketState.estimate_since_compact?.count ?? 0;
168
+ const totalCount = (bucketState.compacted_state?.count ?? 0) + updatedCount;
169
+ const updatedBytes = Number(bucketState.estimate_since_compact?.bytes ?? 0);
170
+ const totalBytes = Number(bucketState.compacted_state?.bytes ?? 0) + updatedBytes;
171
+ return {
172
+ bucket: bucketState._id.b,
173
+ definitionId: null,
174
+ estimatedCount: totalCount,
175
+ dirtyRatio: Math.max(
176
+ totalCount > 0 ? updatedCount / totalCount : 0,
177
+ totalBytes > 0 ? updatedBytes / totalBytes : 0
178
+ )
179
+ };
180
+ });
181
+
182
+ yield dirtyBuckets.filter(
183
+ (bucket) => bucket.estimatedCount >= options.minBucketChanges && bucket.dirtyRatio >= options.minChangeRatio
184
+ );
185
+ }
186
+ }
187
+
188
+ private async dirtyBucketBatchForChecksumsForCollection(
189
+ filter: mongo.Filter<BucketStateDocumentV1>
190
+ ): Promise<DirtyBucket[]> {
191
+ const dirtyBuckets = await this.db.bucketStateV1
192
+ .find(filter, {
193
+ projection: { _id: 1, estimate_since_compact: 1, compacted_state: 1 },
194
+ sort: { 'estimate_since_compact.count': -1 },
195
+ limit: 200,
196
+ maxTimeMS: MONGO_OPERATION_TIMEOUT_MS
197
+ })
198
+ .toArray();
199
+
200
+ return dirtyBuckets.map((bucket) => ({
201
+ bucket: bucket._id.b,
202
+ definitionId: null,
203
+ estimatedCount: Number(bucket.estimate_since_compact!.count) + Number(bucket.compacted_state?.count ?? 0)
204
+ }));
205
+ }
206
+
207
+ private async compactSingleBucketRetried(bucket: string, _definitionId: BucketDefinitionId | null = null) {
208
+ await this.retryCompaction(bucket, () => this.compactSingleBucket(bucket));
209
+ this.compactedBucketCount++;
210
+ }
211
+
212
+ private async compactDirtyBuckets() {
213
+ for await (const buckets of this.dirtyBucketBatches({
214
+ minBucketChanges: this.minBucketChanges,
215
+ minChangeRatio: this.minChangeRatio
216
+ })) {
217
+ this.signal?.throwIfAborted();
218
+ for (const { bucket, definitionId } of buckets) {
219
+ await this.compactSingleBucketRetried(bucket, definitionId);
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Subset of compact, only populating checksums where relevant.
226
+ */
227
+ async populateChecksums(options: { minBucketChanges: number }): Promise<CompactInitialReplicationResults> {
228
+ let count = 0;
229
+ // Paginate through dirty buckets in batches until no more buckets meet the criteria.
230
+ while (true) {
231
+ this.signal?.throwIfAborted();
232
+ const buckets = await this.dirtyBucketBatchForChecksums(options);
233
+ if (buckets.length == 0) {
234
+ break;
235
+ }
236
+ this.signal?.throwIfAborted();
237
+
238
+ const start = Date.now();
239
+ // Filter batch by estimated bucket size, to reduce possibility of timeouts.
240
+ const checkBuckets: typeof buckets = [];
241
+ let totalCountEstimate = 0;
242
+ for (const bucket of buckets) {
243
+ checkBuckets.push(bucket);
244
+ totalCountEstimate += bucket.estimatedCount;
245
+ if (totalCountEstimate > 50_000) {
246
+ break;
247
+ }
248
+ }
249
+ this.logger.info(
250
+ `Calculating checksums for batch of ${buckets.length} buckets, estimated count of ${totalCountEstimate}`
251
+ );
252
+ await this.updateChecksumsBatch(checkBuckets);
253
+ this.logger.info(`Updated checksums for batch of ${checkBuckets.length} buckets in ${Date.now() - start}ms`);
254
+ count += checkBuckets.length;
255
+ }
256
+ return { buckets: count };
257
+ }
258
+
259
+ private collectBucketStateUpdate(
260
+ state: CurrentBucketState,
261
+ compactedOpId: bigint
262
+ ): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
263
+ if (state.opCount < 0) {
264
+ throw new ServiceAssertionError(
265
+ `Invalid opCount: ${state.opCount} checksum ${state.checksum} opsSincePut: ${state.opsSincePut} maxOpId: ${this.maxOpId}`
266
+ );
267
+ }
268
+ return {
269
+ updateOne: {
270
+ filter: this.bucketStateFilter(state.bucket),
271
+ update: {
272
+ $set: {
273
+ compacted_state: {
274
+ op_id: compactedOpId,
275
+ count: state.opCount,
276
+ checksum: BigInt(state.checksum),
277
+ bytes: state.opBytes
278
+ },
279
+ estimate_since_compact: {
280
+ // There could have been a whole bunch of new operations added to the bucket while compacting,
281
+ // which we don't currently cater for. We could potentially query for that, but that adds overhead.
282
+ count: 0,
283
+ bytes: 0
284
+ }
285
+ }
286
+ } satisfies mongo.UpdateFilter<BucketStateDocumentBase>,
287
+ // We generally expect this to have been created before.
288
+ // We don't create new ones here, to avoid issues with the unique index on bucket_updates.
289
+ upsert: false
290
+ }
291
+ };
292
+ }
293
+
294
+ private updateBucketChecksums(state: CurrentBucketState, compactedOpId: bigint) {
295
+ this.bucketStateUpdates.push(this.collectBucketStateUpdate(state, compactedOpId));
296
+ }
297
+
298
+ private async flushBucketStateUpdates() {
299
+ if (this.bucketStateUpdates.length > 0) {
300
+ this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
301
+ await this.writeBucketStateUpdates();
302
+ this.bucketStateUpdates = [];
303
+ }
304
+ }
305
+
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();
56
341
  }
57
342
 
58
343
  protected async writeBucketStateUpdates(): Promise<void> {
@@ -74,10 +359,7 @@ export class MongoCompactorV1 extends MongoCompactor {
74
359
  );
75
360
  }
76
361
 
77
- protected bucketStateFilter(
78
- bucket: string,
79
- _definitionId: BucketDefinitionId | null
80
- ): mongo.Filter<BucketStateDocumentBase> {
362
+ private bucketStateFilter(bucket: string): mongo.Filter<BucketStateDocumentBase> {
81
363
  return {
82
364
  _id: {
83
365
  g: this.group_id,
@@ -95,8 +377,9 @@ export class MongoCompactorV1 extends MongoCompactor {
95
377
  }
96
378
 
97
379
  protected async compactSingleBucket(bucket: string) {
98
- // Do not carry queued writes from a failed attempt into the rescan
380
+ // Do not carry queued writes from a failed attempt into the rescan.
99
381
  this.updates = [];
382
+ this.bucketStateUpdates = [];
100
383
 
101
384
  const idLimitBytes = this.idLimitBytes;
102
385
  const bucketContext = this.getBucketDataContext(bucket);
@@ -200,6 +483,7 @@ export class MongoCompactorV1 extends MongoCompactor {
200
483
  $unset: {
201
484
  source_table: 1,
202
485
  source_key: 1,
486
+ subkey: 1,
203
487
  table: 1,
204
488
  row_id: 1,
205
489
  data: 1
@@ -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 {
@@ -48,6 +48,7 @@ export function serializeBucketDataV1(document: BucketDataDoc): BucketDataDocume
48
48
  op: document.op,
49
49
  source_table: document.source_table,
50
50
  source_key: document.source_key,
51
+ subkey: document.subkey,
51
52
  table: document.table,
52
53
  row_id: document.row_id,
53
54
  checksum: document.checksum,