@powersync/service-module-mongodb-storage 0.20.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 +45 -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 +31 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +22 -0
  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 +90 -19
  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 +84 -4
  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 +6 -6
  106. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  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 +34 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +31 -0
  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 +124 -22
  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 +91 -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 +39 -41
  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 +336 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -24,8 +24,9 @@ import {
24
24
  CurrentDataDocument
25
25
  } from './v1/models.js';
26
26
  import { VersionedPowerSyncMongoV1 } from './v1/VersionedPowerSyncMongoV1.js';
27
- import { BucketDataDocumentV3 } from './v3/models.js';
28
- import { VersionedPowerSyncMongoV3 } from './v3/VersionedPowerSyncMongoV3.js';
27
+ import { BucketDataDocumentV3, CustomCheckpointRequestDocumentV3 } from './v3/models.js';
28
+ import type { CustomCheckpointRequestCollectionSpecifier } from './v3/VersionedPowerSyncMongoV3.js';
29
+ import { OBJECT_STORAGE_USAGE_COLLECTION, VersionedPowerSyncMongoV3 } from './v3/VersionedPowerSyncMongoV3.js';
29
30
 
30
31
  export interface PowerSyncMongoOptions {
31
32
  /**
@@ -137,6 +138,20 @@ export class PowerSyncMongo {
137
138
  return `source_table_${replicationStreamId}`;
138
139
  }
139
140
 
141
+ customCheckpointRequestCollectionName(options: CustomCheckpointRequestCollectionSpecifier) {
142
+ return `custom_checkpoint_requests_${options.replicationStreamId}_${options.eventId}`;
143
+ }
144
+
145
+ async listCustomCheckpointRequestCollections(
146
+ replicationStreamId?: number
147
+ ): Promise<mongo.Collection<CustomCheckpointRequestDocumentV3>[]> {
148
+ const prefix =
149
+ replicationStreamId == null
150
+ ? 'custom_checkpoint_requests_'
151
+ : `custom_checkpoint_requests_${replicationStreamId}_`;
152
+ return this.collectionsByPrefix(prefix);
153
+ }
154
+
140
155
  async listSourceTableCollections(
141
156
  replicationStreamId?: number
142
157
  ): Promise<mongo.Collection<CommonSourceTableDocument>[]> {
@@ -178,6 +193,10 @@ export class PowerSyncMongo {
178
193
  await this.locks.deleteMany({});
179
194
  await this.bucket_state.deleteMany({});
180
195
  await this.custom_write_checkpoints.deleteMany({});
196
+ await this.db.collection(OBJECT_STORAGE_USAGE_COLLECTION).deleteMany({});
197
+ for (const collection of await this.listCustomCheckpointRequestCollections()) {
198
+ await collection.drop();
199
+ }
181
200
  }
182
201
 
183
202
  /**
@@ -74,6 +74,12 @@ export interface BucketDataProperties {
74
74
  op: OpType;
75
75
  source_table?: bson.ObjectId;
76
76
  source_key?: ReplicaId;
77
+ /**
78
+ * Stable identifier for the source record in the client protocol.
79
+ *
80
+ * This is derived from (source_table, source_key), so persistence is optional but preferred.
81
+ */
82
+ subkey?: string;
77
83
  table?: string;
78
84
  row_id?: string;
79
85
  checksum: bigint;
@@ -215,6 +221,34 @@ export interface SyncRuleDocumentBase {
215
221
  } | null;
216
222
 
217
223
  storage_version?: number;
224
+
225
+ /** Optional operator-supplied label identifying the sync config version. */
226
+ version_label?: string | null;
227
+
228
+ /**
229
+ * Incremental parameter compaction state for the replication stream.
230
+ *
231
+ * Operation ids are allocated across all parameter indexes of a stream, so this is
232
+ * stream-level state, shared by all sync configs of the stream.
233
+ */
234
+ parameter_compaction?: {
235
+ /**
236
+ * The exclusive operation-id boundary through which every parameter index in this stream has
237
+ * been compacted.
238
+ */
239
+ compacted_before: InternalOpId;
240
+
241
+ /**
242
+ * Parameter entries below this boundary may no longer be available for checkpoint change
243
+ * detection, since compaction may have deleted them.
244
+ *
245
+ * This is advanced before the first delete of a compaction pass, while
246
+ * {@link compacted_before} is only advanced after every delete of the pass completed.
247
+ * A checkpoint transition starting below this boundary must conservatively invalidate all
248
+ * parameter buckets, since the individual changes may no longer be available.
249
+ */
250
+ checkpoint_changes_invalid_before?: InternalOpId;
251
+ };
218
252
  }
219
253
 
220
254
  export interface SyncRuleCheckpointFields {
@@ -1,3 +1,4 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
1
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
2
3
  import { ColumnDescriptor, InternalOpId, SourceTable, storage } from '@powersync/service-core';
3
4
  import { HydratedSyncConfig } from '@powersync/service-sync-rules';
@@ -50,6 +51,36 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
50
51
  // No-op for V1: source records live in a shared collection.
51
52
  }
52
53
 
54
+ protected override async batchCreateCustomWriteCheckpoints(
55
+ session: mongo.ClientSession,
56
+ opId: InternalOpId
57
+ ): Promise<void> {
58
+ await this.db.custom_write_checkpoints.bulkWrite(
59
+ this.write_checkpoint_batch.map((checkpointOptions) => {
60
+ const set: Record<string, unknown> = {
61
+ checkpoint: checkpointOptions.checkpoint,
62
+ sync_rules_id: checkpointOptions.sync_rules_id,
63
+ op_id: opId
64
+ };
65
+ if (checkpointOptions.checkpoint_requested_at != null) {
66
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
67
+ }
68
+
69
+ return {
70
+ updateOne: {
71
+ filter: { user_id: checkpointOptions.user_id, sync_rules_id: checkpointOptions.sync_rules_id },
72
+ update: {
73
+ $set: set,
74
+ ...(checkpointOptions.checkpoint_requested_at == null ? { $unset: { checkpoint_requested_at: 1 } } : {})
75
+ },
76
+ upsert: true
77
+ }
78
+ };
79
+ }),
80
+ { session }
81
+ );
82
+ }
83
+
53
84
  async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
54
85
  const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
55
86
  const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
@@ -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
  }