@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,5 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { logger } from '@powersync/lib-services-framework';
2
+ import { logger as defaultLogger, Logger } from '@powersync/lib-services-framework';
3
3
  import { bson, CompactOptions, InternalOpId } from '@powersync/service-core';
4
4
  import { LRUCache } from 'lru-cache';
5
5
  import type { VersionedPowerSyncMongo } from './db.js';
@@ -7,144 +7,430 @@ import type { VersionedPowerSyncMongo } from './db.js';
7
7
  type ParameterCompactionReadDocument = {
8
8
  _id: InternalOpId;
9
9
  key: mongo.Document;
10
- lookup: unknown;
10
+ lookup: bson.Binary;
11
11
  bucket_parameters?: unknown[] | null;
12
12
  };
13
13
 
14
+ export type ParameterCompactionResult = {
15
+ collections: number;
16
+ scannedEntries: number;
17
+ deletedEntries: number;
18
+ };
19
+
20
+ const PARAMETER_COMPACTION_BATCH_SIZE = 10_000;
21
+ const PARAMETER_COMPACTION_DELETE_BATCH_SIZE = 1_000;
22
+ const PARAMETER_COMPACTION_CACHE_SIZE = 50_000;
23
+ /**
24
+ * How often progress is persisted during a pass.
25
+ *
26
+ * Kept coarse: replication also updates the `sync_rules` document on every commit.
27
+ */
28
+ const PARAMETER_COMPACTION_PERSIST_INTERVAL_MS = 60_000;
29
+
30
+ type CachedIdentity = {
31
+ /**
32
+ * The `_id` of the document retained for this identity in a previous batch, or null if that
33
+ * document was a tombstone - in which case it has been deleted along with all its history, and
34
+ * nothing remains to delete for the identity.
35
+ */
36
+ retainedId: InternalOpId | null;
37
+ };
38
+
39
+ type LeadingHistoryDelete = {
40
+ lookup: bson.Binary;
41
+ keys: mongo.Document[];
42
+ };
43
+
44
+ /**
45
+ * One collection being compacted, and how far this pass has processed it.
46
+ */
47
+ type CompactionScope = {
48
+ collection: mongo.Collection<mongo.Document>;
49
+ /**
50
+ * Exclusive boundary: every entry below this has been processed in this collection.
51
+ *
52
+ * Set to the target checkpoint once the collection has no more entries in range.
53
+ */
54
+ position: InternalOpId;
55
+ scannedEntries: number;
56
+ deletedEntries: number;
57
+ };
58
+
14
59
  /**
15
60
  * Compacts parameter lookup data (the bucket_parameters collection).
16
61
  *
17
- * This scans through the entire collection to find data to compact.
62
+ * Both storage versions persist a per-stream compaction cursor, so a run only scans entries in the
63
+ * un-compacted operation-id range. V1 scans its shared collection using only the `_id` index, so it
64
+ * additionally filters by stream in code.
65
+ *
66
+ * The cursor is a single value covering every collection of the stream, so it can only be advanced
67
+ * to a boundary that all collections have passed. To keep it moving during a long pass, collections
68
+ * are processed in lock-step rather than one after another - see {@link compactCollections}.
18
69
  *
19
70
  * For background, see the `/docs/storage/parameter-lookups.md` file.
20
71
  */
21
- export class MongoParameterCompactor {
72
+ export abstract class MongoParameterCompactor {
73
+ protected readonly logger: Logger;
74
+ protected readonly signal?: AbortSignal;
75
+
22
76
  constructor(
23
77
  protected readonly db: VersionedPowerSyncMongo,
24
- protected readonly group_id: number,
78
+ protected readonly replicationStreamId: number,
25
79
  protected readonly checkpoint: InternalOpId,
26
80
  protected readonly options: CompactOptions,
27
- protected readonly getCollectionsCb?: () => Promise<mongo.Collection<mongo.Document>[]>
28
- ) {}
81
+ protected readonly parameterCompactionBatchSize = PARAMETER_COMPACTION_BATCH_SIZE,
82
+ protected readonly parameterCompactionPersistIntervalMs = PARAMETER_COMPACTION_PERSIST_INTERVAL_MS
83
+ ) {
84
+ this.logger = options.logger ?? defaultLogger;
85
+ this.signal = options.signal;
86
+ }
87
+
88
+ /**
89
+ * Set once the invalidation fence for this pass has been persisted. See
90
+ * {@link ensureInvalidationFence}.
91
+ */
92
+ #invalidationFencePersisted = false;
29
93
 
30
94
  async compact() {
31
- logger.info(`Compacting parameters for sync config ${this.group_id} up to checkpoint ${this.checkpoint}`);
32
- for (const collection of await this.getCollections()) {
33
- await this.compactCollection(collection);
34
- }
95
+ const startedAt = Date.now();
96
+ this.signal?.throwIfAborted();
97
+ const compactedBefore = await this.readCompactedBefore();
98
+ this.logger.info(`Incrementally compacting parameters from ${compactedBefore} up to checkpoint ${this.checkpoint}`);
99
+
100
+ const result = await this.compactCollections(compactedBefore);
101
+
102
+ // Persist only after every collection has completed. This uses $max so an overlapping
103
+ // compactor cannot move the cursor backwards.
104
+ await this.persistCompactedBefore(this.checkpoint);
105
+
106
+ const durationSeconds = (Date.now() - startedAt) / 1000;
107
+ this.logger.info(
108
+ `Incremental parameter compaction completed: ` +
109
+ `collections=${result.collections}, scanned=${result.scannedEntries}, ` +
110
+ `deleted=${result.deletedEntries}, cursor=${compactedBefore}->${this.checkpoint}, ` +
111
+ `fence=${this.#invalidationFencePersisted ? this.checkpoint : 'unchanged'}, duration=${durationSeconds.toFixed(1)}s`
112
+ );
35
113
  }
36
114
 
37
- protected async getCollections(): Promise<mongo.Collection<mongo.Document>[]> {
38
- if (this.getCollectionsCb == null) {
39
- throw new Error('getCollections callback not provided');
40
- }
41
- const collections = await this.getCollectionsCb();
42
- // Cast from the version-specific collection type to the generic Document type
43
- // used by the parameter compactor base class.
44
- return collections.map((collection) => collection as unknown as mongo.Collection<mongo.Document>);
115
+ /**
116
+ * The exclusive operation-id boundary through which this stream's parameter indexes have all
117
+ * been compacted.
118
+ */
119
+ protected async readCompactedBefore(): Promise<InternalOpId> {
120
+ const stream = await this.db.sync_rules.findOne(
121
+ { _id: this.replicationStreamId },
122
+ { projection: { parameter_compaction: 1 } }
123
+ );
124
+ return stream?.parameter_compaction?.compacted_before == null
125
+ ? 0n
126
+ : BigInt(stream.parameter_compaction.compacted_before);
45
127
  }
46
128
 
47
- protected collectionFilter(): mongo.Document {
48
- return {};
129
+ protected async persistCompactedBefore(compactedBefore: InternalOpId): Promise<void> {
130
+ await this.db.sync_rules.updateOne(
131
+ { _id: this.replicationStreamId },
132
+ {
133
+ $max: { 'parameter_compaction.compacted_before': compactedBefore }
134
+ }
135
+ );
49
136
  }
50
137
 
51
- protected deleteFilter(doc: mongo.Document): mongo.Document {
52
- return {
53
- lookup: doc.lookup,
54
- _id: { $lte: doc._id },
55
- key: doc.key
56
- };
138
+ /**
139
+ * Commits the checkpoint-change invalidation fence before the first delete of this pass.
140
+ *
141
+ * Checkpoint change detection finds changed lookups by querying parameter entries in
142
+ * (lastCheckpoint, nextCheckpoint]. Compaction physically removes entries in that range, so a
143
+ * checkpoint that can no longer see the full history must instead invalidate all parameter
144
+ * buckets. The fence records the boundary below which that history may be missing.
145
+ *
146
+ * The fence must be committed before the first delete: MongoDB snapshot ordering then
147
+ * guarantees that a checkpoint snapshot which observes a deletion also observes the fence.
148
+ *
149
+ * This deliberately isn't the same value as the compaction cursor. If the pass fails halfway,
150
+ * the fence only causes conservative invalidation, while an advanced cursor would skip
151
+ * deletion work that never completed.
152
+ */
153
+ private async ensureInvalidationFence(): Promise<void> {
154
+ // We can consider incrementally updating the fence based on the current cursor position instead
155
+ // of the checkpoint. That would result in lower risk of triggering invalidations, but it would
156
+ // result in a higher number of updates to the `sync_rules` collection, which can make it
157
+ // counter-productive.
158
+ // Another option is to introduce an artificial delay of a couple of seconds before writing the fence,
159
+ // giving some chance for every API process to catch up. Note that the delay would have to apply
160
+ // to both the deletes and the fence - the fence write must still happen before we do any deletes.
161
+ if (this.#invalidationFencePersisted) {
162
+ return;
163
+ }
164
+ await this.db.sync_rules.updateOne(
165
+ { _id: this.replicationStreamId },
166
+ {
167
+ $max: { 'parameter_compaction.checkpoint_changes_invalid_before': this.checkpoint }
168
+ }
169
+ );
170
+ this.#invalidationFencePersisted = true;
57
171
  }
58
172
 
59
- protected async compactCollection(collection: mongo.Collection<mongo.Document>) {
60
- // This is the currently-active checkpoint.
61
- // We do not remove any data that may be used by this checkpoint.
62
- // snapshot queries ensure that if any clients are still using older checkpoints, they would
63
- // not be affected by this compaction.
64
- const checkpoint = this.checkpoint;
65
-
66
- // Index on {'key.g': 1, lookup: 1, _id: 1}
67
- // In theory, we could let MongoDB do more of the work here, by grouping by (key, lookup)
68
- // in MongoDB already. However, that risks running into cases where MongoDB needs to process
69
- // very large amounts of data before returning results, which could lead to timeouts.
70
- const cursor = collection.find(this.collectionFilter(), {
71
- sort: { lookup: 1, _id: 1 },
72
- batchSize: 10_000,
73
- projection: { _id: 1, key: 1, lookup: 1, bucket_parameters: 1 }
74
- });
173
+ protected abstract getCollections(): Promise<mongo.Collection<mongo.Document>[]>;
174
+
175
+ protected abstract shouldCompactDocument(doc: ParameterCompactionReadDocument): boolean;
75
176
 
76
- // The index doesn't cover sorting by key, so we keep our own cache of the last seen key.
77
- let lastByKey = new LRUCache<string, InternalOpId>({
78
- max: this.options.compactParameterCacheLimit ?? 10_000
177
+ /** Deletes history preceding a batch for several identities sharing a lookup. */
178
+ protected abstract leadingHistoryDeleteFilter(
179
+ lookup: bson.Binary,
180
+ keys: mongo.Document[],
181
+ before: InternalOpId
182
+ ): mongo.Document;
183
+
184
+ /**
185
+ * Processes every collection of the stream, in lock-step: each turn takes one batch from the
186
+ * collection that has processed the least so far.
187
+ *
188
+ * The persisted cursor is the minimum position over all collections, which is exactly the
189
+ * boundary that all of them have passed, so it can be advanced periodically during the pass.
190
+ * Always picking the collection that is furthest behind also keeps each of them within one batch
191
+ * of that boundary, bounding the work an interrupted pass has to repeat.
192
+ *
193
+ * V1 storage always has a single collection; V3 has a collection per defined index. So in V1 the
194
+ * ame process collapses to compacting the single collection in order, while V3 can alternate between
195
+ * collections.
196
+ */
197
+ private async compactCollections(compactedBefore: InternalOpId): Promise<ParameterCompactionResult> {
198
+ const scopes: CompactionScope[] = (await this.getCollections()).map((collection) => ({
199
+ collection,
200
+ position: compactedBefore,
201
+ scannedEntries: 0,
202
+ deletedEntries: 0
203
+ }));
204
+ // Shared by all scopes, so the memory bound does not depend on the number of parameter indexes.
205
+ // It is safe for items to be evicted: that just changes deletes from "delete by _id" to
206
+ // the more expensive "delete by range filter".
207
+ const previousByIdentity = new LRUCache<string, CachedIdentity>({
208
+ max: this.options.compactParameterCacheLimit ?? PARAMETER_COMPACTION_CACHE_SIZE
79
209
  });
80
- let removeIds: InternalOpId[] = [];
81
- let removeDeleted: mongo.AnyBulkWriteOperation<mongo.Document>[] = [];
82
- let checkedEntries = 0;
83
- let checkedEntriesAtLastLog = 0;
84
- let lastProgressLogTime = Date.now();
85
-
86
- const flush = async (force: boolean) => {
87
- if (removeIds.length >= 1000 || (force && removeIds.length > 0)) {
88
- // MongoDB Filter<T> doesn't fully match our dynamic delete filter shape here.
89
- const results = await collection.deleteMany({ _id: { $in: removeIds } } as any);
90
- logger.info(`Removed ${results.deletedCount} (${removeIds.length}) superseded parameter entries`);
91
- removeIds = [];
210
+ let persistedFrontier = compactedBefore;
211
+ let lastPersistedAt = Date.now();
212
+
213
+ while (true) {
214
+ // Interrupting between batches is equivalent to a crash: deletes are idempotent, and the
215
+ // cursor never covers a batch that did not complete.
216
+ this.signal?.throwIfAborted();
217
+ const { frontier, scope } = this.frontier(scopes);
218
+
219
+ if (frontier > persistedFrontier && Date.now() - lastPersistedAt >= this.parameterCompactionPersistIntervalMs) {
220
+ await this.persistCompactedBefore(frontier);
221
+ persistedFrontier = frontier;
222
+ lastPersistedAt = Date.now();
223
+ this.logger.info(`Parameter compaction progress: ` + `cursor=${frontier}, target=${this.checkpoint}`);
224
+ }
225
+
226
+ if (scope == null) {
227
+ // All scopes have been processed up to the target checkpoint.
228
+ break;
92
229
  }
93
230
 
94
- if (removeDeleted.length > 10 || (force && removeDeleted.length > 0)) {
95
- const results = await collection.bulkWrite(removeDeleted);
96
- logger.info(`Removed ${results.deletedCount} (${removeDeleted.length}) deleted parameter entries`);
97
- removeDeleted = [];
231
+ // The scope on the frontier has processed the least, so taking its next batch is what keeps
232
+ // every scope within one batch of the cursor.
233
+ await this.compactBatch(scope, previousByIdentity);
234
+
235
+ if (scope.position >= this.checkpoint && scope.scannedEntries > 0) {
236
+ this.logger.info(
237
+ `Parameter compaction completed for ${scope.collection.collectionName}: ` +
238
+ `scanned=${scope.scannedEntries}, deleted=${scope.deletedEntries}`
239
+ );
98
240
  }
241
+ }
242
+
243
+ return {
244
+ collections: scopes.length,
245
+ scannedEntries: scopes.reduce((total, scope) => total + scope.scannedEntries, 0),
246
+ deletedEntries: scopes.reduce((total, scope) => total + scope.deletedEntries, 0)
99
247
  };
248
+ }
100
249
 
101
- while (await cursor.hasNext()) {
102
- // readBufferedDocuments returns a generic type; we know the shape from our projection.
103
- const batch = cursor.readBufferedDocuments() as unknown as ParameterCompactionReadDocument[];
104
- checkedEntries += batch.length;
105
- const now = Date.now();
106
- if (now - lastProgressLogTime >= 60_000) {
107
- const elapsedSeconds = (now - lastProgressLogTime) / 1000;
108
- const rate = (checkedEntries - checkedEntriesAtLastLog) / elapsedSeconds;
109
- logger.info(`Checked ${checkedEntries} parameter index entries for compaction (${rate.toFixed(1)} entries/s)`);
110
- lastProgressLogTime = now;
111
- checkedEntriesAtLastLog = checkedEntries;
250
+ /**
251
+ * The boundary that every scope has processed past, capped at the target checkpoint, and the
252
+ * scope sitting on it.
253
+ *
254
+ * The frontier is the furthest the cursor may be advanced. The scope is the one that has
255
+ * processed the least, or null once all of them have reached the target checkpoint.
256
+ */
257
+ private frontier(scopes: CompactionScope[]): { frontier: InternalOpId; scope: CompactionScope | null } {
258
+ let frontier = this.checkpoint;
259
+ let scope: CompactionScope | null = null;
260
+ for (const candidate of scopes) {
261
+ if (candidate.position < frontier) {
262
+ frontier = candidate.position;
263
+ scope = candidate;
112
264
  }
265
+ }
266
+ return { frontier, scope };
267
+ }
113
268
 
114
- for (const doc of batch) {
115
- if (doc._id >= checkpoint) {
116
- continue;
117
- }
118
- const uniqueKey = (
119
- bson.serialize({
120
- k: doc.key,
121
- l: doc.lookup
122
- }) as Buffer
123
- ).toString('base64');
124
- const previous = lastByKey.get(uniqueKey);
125
- if (previous != null && previous < doc._id) {
126
- // We have a newer entry for the same key, so we can remove the old one.
127
- removeIds.push(previous);
269
+ /**
270
+ * Reads and processes one batch from the scope, and advances its position past that batch.
271
+ */
272
+ private async compactBatch(scope: CompactionScope, previousByIdentity: LRUCache<string, CachedIdentity>) {
273
+ const batchStartedAt = Date.now();
274
+ const collection = scope.collection;
275
+ // Typed as Document: `_id` here is an InternalOpId (bigint), not the driver's default ObjectId.
276
+ const filter: mongo.Document = { _id: { $gte: scope.position, $lt: this.checkpoint } };
277
+ const batch = (await collection
278
+ .find(filter, {
279
+ sort: { _id: 1 },
280
+ limit: this.parameterCompactionBatchSize,
281
+ batchSize: this.parameterCompactionBatchSize + 1,
282
+ projection: { _id: 1, key: 1, lookup: 1, bucket_parameters: { $slice: 1 } }
283
+ })
284
+ .toArray()) as unknown as ParameterCompactionReadDocument[];
285
+
286
+ if (batch.length < this.parameterCompactionBatchSize) {
287
+ // Fewer documents than we asked for: this collection has nothing left in the range.
288
+ scope.position = this.checkpoint;
289
+ } else {
290
+ scope.position = batch.at(-1)!._id + 1n;
291
+ }
292
+ if (batch.length == 0) {
293
+ return;
294
+ }
295
+ scope.scannedEntries += batch.length;
296
+ const deletedBeforeBatch = scope.deletedEntries;
297
+
298
+ // Keep the latest document for each identity and remove all earlier documents from this
299
+ // batch by _id, avoiding a range query for documents that have already been read.
300
+ const newestByIdentity = new Map<string, ParameterCompactionReadDocument>();
301
+ const supersededIds: InternalOpId[] = [];
302
+ for (const document of batch) {
303
+ if (!this.shouldCompactDocument(document)) {
304
+ continue;
305
+ }
306
+ const identity = identityKey(scope, document);
307
+ const previous = newestByIdentity.get(identity);
308
+ if (previous != null) {
309
+ supersededIds.push(previous._id);
310
+ }
311
+ newestByIdentity.set(identity, document);
312
+ }
313
+
314
+ const leadingHistoryDeletes = new Map<string, LeadingHistoryDelete>();
315
+ const tombstoneIds: InternalOpId[] = [];
316
+ for (const [identity, document] of newestByIdentity) {
317
+ const previous = previousByIdentity.get(identity);
318
+ if (previous == null) {
319
+ // Have not seen this (key, lookup) before, or it has been evicted from the cache.
320
+ // Delete the entire leading range.
321
+ // This should have decent performance on V3 storage; can be slow in some cases on V1.
322
+ const lookupIdentity = document.lookup.toString('base64');
323
+ const existing = leadingHistoryDeletes.get(lookupIdentity);
324
+ if (existing == null) {
325
+ leadingHistoryDeletes.set(lookupIdentity, { lookup: document.lookup, keys: [document.key] });
326
+ } else {
327
+ existing.keys.push(document.key);
128
328
  }
129
- lastByKey.set(uniqueKey, doc._id);
130
-
131
- if (doc.bucket_parameters?.length == 0) {
132
- // This is a delete operation, so we can remove it completely.
133
- // For this we cannot remove the operation itself only: There is a possibility that
134
- // there is still an earlier operation with the same key and lookup, that we don't have
135
- // in the cache due to cache size limits. So we need to explicitly remove all earlier operations.
136
- removeDeleted.push({
137
- deleteMany: {
138
- filter: this.deleteFilter(doc)
139
- }
140
- });
329
+ } else if (previous.retainedId != null) {
330
+ // We have already deleted the leading range for this (key, lookup). Only delete the last remaining
331
+ // one by _id. This is always fast.
332
+ supersededIds.push(previous.retainedId);
333
+ }
334
+
335
+ if (document.bucket_parameters?.length == 0) {
336
+ tombstoneIds.push(document._id);
337
+ }
338
+ }
339
+
340
+ // Phase 1: Delete documents read in this batch, plus retained documents from a prior batch.
341
+ scope.deletedEntries += await this.deleteByIds(collection, supersededIds);
342
+
343
+ // Phase 2: Delete leading history once per lookup group. The batch is read with
344
+ // `_id < checkpoint`, so this range is checkpoint-bounded.
345
+ const deleteBefore = batch[0]._id;
346
+ // The deletes are collected into bulkWrite commands: With high lookup cardinality there is a
347
+ // group per identity, and a command per group would mean a round trip per identity.
348
+ let deleteOperations: mongo.AnyBulkWriteOperation[] = [];
349
+ let pendingKeys = 0;
350
+ const flushDeleteOperations = async () => {
351
+ if (deleteOperations.length == 0) {
352
+ return;
353
+ }
354
+ // Safe to stop here: an interrupted batch leaves phase 3 tombstones in place, and the
355
+ // remaining deletes are repeated by the next pass.
356
+ this.signal?.throwIfAborted();
357
+ await this.ensureInvalidationFence();
358
+ const result = await collection.bulkWrite(deleteOperations, { ordered: false });
359
+ scope.deletedEntries += result.deletedCount;
360
+ deleteOperations = [];
361
+ pendingKeys = 0;
362
+ };
363
+ for (const { lookup, keys } of leadingHistoryDeletes.values()) {
364
+ for (const keyBatch of chunk(keys, PARAMETER_COMPACTION_DELETE_BATCH_SIZE)) {
365
+ deleteOperations.push({
366
+ deleteMany: { filter: this.leadingHistoryDeleteFilter(lookup, keyBatch, deleteBefore) }
367
+ });
368
+ // Bound the command size by the total number of keys it covers, not by the number of
369
+ // operations: a single group may already cover the entire batch.
370
+ pendingKeys += keyBatch.length;
371
+ if (pendingKeys >= PARAMETER_COMPACTION_DELETE_BATCH_SIZE) {
372
+ await flushDeleteOperations();
141
373
  }
142
374
  }
375
+ }
376
+ // Phase 3 requires all leading history to be deleted first.
377
+ await flushDeleteOperations();
378
+
379
+ // Phase 3: A tombstone is removed only after all preceding history has been removed.
380
+ scope.deletedEntries += await this.deleteByIds(collection, tombstoneIds);
381
+
382
+ // Update the LRU only after all phases succeed. An evicted identity safely falls back to a
383
+ // grouped leading-history delete when it appears again.
384
+ for (const [identity, document] of newestByIdentity) {
385
+ // Tombstones are recorded as `retainedId: null`: phases 2 and 3 removed the entire history
386
+ // for the identity, including the tombstone, so a later sighting needs neither delete.
387
+ previousByIdentity.set(identity, {
388
+ retainedId: document.bucket_parameters?.length == 0 ? null : document._id
389
+ });
390
+ }
143
391
 
144
- await flush(false);
392
+ const batchDurationSeconds = (Date.now() - batchStartedAt) / 1000;
393
+ this.logger.info(
394
+ `Compacted parameter batch in ${collection.collectionName}: ` +
395
+ `_id ${batch[0]._id}..${batch.at(-1)!._id}, scanned=${batch.length} (${scope.scannedEntries} total), ` +
396
+ `batchIdentities=${newestByIdentity.size}, exactIds=${supersededIds.length + tombstoneIds.length}, ` +
397
+ `lookupGroups=${leadingHistoryDeletes.size}, deleted=${scope.deletedEntries - deletedBeforeBatch}, ` +
398
+ `duration=${batchDurationSeconds.toFixed(1)}s`
399
+ );
400
+ }
401
+
402
+ /** Deletes documents by `_id`, chunked to bound the command size. Returns the number deleted. */
403
+ private async deleteByIds(collection: mongo.Collection<mongo.Document>, ids: InternalOpId[]): Promise<number> {
404
+ let deletedEntries = 0;
405
+ for (const idBatch of chunk(ids, PARAMETER_COMPACTION_DELETE_BATCH_SIZE)) {
406
+ this.signal?.throwIfAborted();
407
+ await this.ensureInvalidationFence();
408
+ // Cast: `_id` here is an InternalOpId (bigint), not the driver's default ObjectId.
409
+ const result = await collection.deleteMany({ _id: { $in: idBatch } } as any);
410
+ deletedEntries += result.deletedCount;
145
411
  }
412
+ return deletedEntries;
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Identifies a (key, lookup) pair within one collection.
418
+ *
419
+ * The collection is part of the identity: V3 keeps the parameter index id in the collection name
420
+ * rather than in the lookup, so the same (key, lookup) can appear in multiple collections meaning
421
+ * different things. Deleting the history of one says nothing about the other.
422
+ */
423
+ function identityKey(scope: CompactionScope, document: ParameterCompactionReadDocument): string {
424
+ const serialized = bson.serialize({
425
+ c: scope.collection.collectionName,
426
+ k: document.key,
427
+ l: document.lookup
428
+ }) as Buffer;
429
+ return serialized.toString('base64');
430
+ }
146
431
 
147
- await flush(true);
148
- logger.info(`Parameter compaction completed for ${collection.collectionName}`);
432
+ function* chunk<T>(items: T[], size: number): Iterable<T[]> {
433
+ for (let offset = 0; offset < items.length; offset += size) {
434
+ yield items.slice(offset, offset + size);
149
435
  }
150
436
  }
@@ -12,6 +12,8 @@ import {
12
12
  import {
13
13
  CompatibilityOption,
14
14
  DEFAULT_HYDRATION_STATE,
15
+ EventDefinitionId,
16
+ HydratedEventDescriptor,
15
17
  HydratedSyncConfig,
16
18
  HydrationState,
17
19
  nodeSqlite,
@@ -27,6 +29,8 @@ export class MongoParsedSyncConfigSet implements storage.ParsedSyncConfigSet {
27
29
  public readonly replicationStreamName: string;
28
30
  public readonly mapping: BucketDefinitionMapping;
29
31
 
32
+ readonly #configsWithMapping: readonly SyncConfigWithMapping[];
33
+
30
34
  constructor(
31
35
  public readonly replicationStreamId: number,
32
36
  storageConfig: StorageConfig,
@@ -34,6 +38,7 @@ export class MongoParsedSyncConfigSet implements storage.ParsedSyncConfigSet {
34
38
  syncConfigs: SyncConfigWithMapping[]
35
39
  ) {
36
40
  this.replicationStreamName = slotName;
41
+ this.#configsWithMapping = [...syncConfigs];
37
42
  this.syncConfigs = syncConfigs.map((config) => config.syncConfig);
38
43
  if (this.syncConfigs.length == 0) {
39
44
  throw new ServiceAssertionError(`At least one sync config is required`);
@@ -85,4 +90,33 @@ export class MongoParsedSyncConfigSet implements storage.ParsedSyncConfigSet {
85
90
  });
86
91
  return this.#hydratedSyncConfig;
87
92
  }
93
+
94
+ #eventById: ReadonlyMap<EventDefinitionId, HydratedEventDescriptor> | undefined;
95
+
96
+ /**
97
+ * Hydrated events for the replication stream, keyed by their assigned storage id.
98
+ *
99
+ * Each config's events are resolved against that config's own (single-config) mapping, so the resolution is
100
+ * unambiguous, and the result is deduplicated by assigned id: unchanged events shared across configs collapse to
101
+ * one entry, while a changed event keeps a separate entry under its new id.
102
+ */
103
+ get eventById(): ReadonlyMap<EventDefinitionId, HydratedEventDescriptor> {
104
+ if (this.#eventById == null) {
105
+ const map = new Map<EventDefinitionId, HydratedEventDescriptor>();
106
+ const byDefinition = this.hydratedSyncConfig.eventDescriptorsByDefinition;
107
+ for (const config of this.#configsWithMapping) {
108
+ if (config.mapping == null) {
109
+ continue;
110
+ }
111
+ for (const event of byDefinition.get(config.syncConfig.config) ?? []) {
112
+ const id = config.mapping.eventId(event);
113
+ if (!map.has(id)) {
114
+ map.set(id, event);
115
+ }
116
+ }
117
+ }
118
+ this.#eventById = map;
119
+ }
120
+ return this.#eventById;
121
+ }
88
122
  }
@@ -70,7 +70,8 @@ export class MongoPersistedSyncConfigContentV1 extends MongoPersistedSyncConfigC
70
70
  storageVersion: doc.storage_version ?? storage.LEGACY_STORAGE_VERSION,
71
71
  mapping: new SingleSyncConfigBucketDefinitionMapping(),
72
72
  syncConfigId: null,
73
- syncConfigState: doc.state
73
+ syncConfigState: doc.state,
74
+ version_label: doc.version_label ?? undefined
74
75
  });
75
76
  }
76
77
 
@@ -100,7 +101,8 @@ export class MongoPersistedSyncConfigContentV3 extends MongoPersistedSyncConfigC
100
101
  storageVersion: doc.storage_version,
101
102
  mapping: SingleSyncConfigBucketDefinitionMapping.fromPersistedMapping(config.rule_mapping),
102
103
  syncConfigId: config._id,
103
- syncConfigState: state.state
104
+ syncConfigState: state.state,
105
+ version_label: config.version_label
104
106
  });
105
107
  }
106
108