@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260803144812

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 (239) hide show
  1. package/CHANGELOG.md +101 -5
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +41 -16
  6. package/dist/storage/MongoBucketStorage.js +226 -71
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +56 -25
  9. package/dist/storage/implementation/MongoBucketBatch.js +110 -62
  10. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  11. package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
  12. package/dist/storage/implementation/MongoChecksums.js +36 -121
  13. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  14. package/dist/storage/implementation/MongoCompactor.d.ts +17 -19
  15. package/dist/storage/implementation/MongoCompactor.js +80 -263
  16. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  17. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  18. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  19. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  20. package/dist/storage/implementation/MongoParameterCompactor.d.ts +7 -6
  21. package/dist/storage/implementation/MongoParameterCompactor.js +25 -2
  22. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  23. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  24. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  25. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  27. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  28. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  29. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  30. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  31. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  32. package/dist/storage/implementation/MongoStorageProvider.js +39 -4
  33. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +57 -29
  35. package/dist/storage/implementation/MongoSyncBucketStorage.js +87 -61
  36. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  37. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  38. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  39. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +171 -24
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +8 -0
  44. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  45. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  46. package/dist/storage/implementation/common/PersistedBatch.d.ts +7 -2
  47. package/dist/storage/implementation/common/PersistedBatch.js +12 -0
  48. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  49. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  50. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  51. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  52. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  53. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  54. package/dist/storage/implementation/db.js +17 -15
  55. package/dist/storage/implementation/db.js.map +1 -1
  56. package/dist/storage/implementation/models.d.ts +20 -13
  57. package/dist/storage/implementation/models.js.map +1 -1
  58. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  59. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +61 -29
  60. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  61. package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
  62. package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
  63. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  64. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  65. package/dist/storage/implementation/v1/MongoCompactorV1.js +251 -2
  66. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  67. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -19
  68. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +82 -58
  69. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  70. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  71. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  72. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  73. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  74. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  75. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  76. package/dist/storage/implementation/v1/models.d.ts +10 -1
  77. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  78. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  79. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  80. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  81. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +309 -259
  82. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  83. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
  84. package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
  85. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  86. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +40 -2
  87. package/dist/storage/implementation/v3/MongoCompactorV3.js +647 -23
  88. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  89. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +67 -0
  90. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +351 -0
  91. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  92. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +45 -30
  93. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +272 -140
  94. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  95. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +6 -3
  96. package/dist/storage/implementation/v3/PersistedBatchV3.js +84 -21
  97. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  98. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  99. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  100. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  101. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +15 -13
  102. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +32 -27
  103. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  104. package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
  105. package/dist/storage/implementation/v3/bucket-format.js +77 -0
  106. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  107. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  108. package/dist/storage/implementation/v3/chunking.js +30 -0
  109. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  110. package/dist/storage/implementation/v3/models.d.ts +101 -21
  111. package/dist/storage/implementation/v3/models.js +11 -28
  112. package/dist/storage/implementation/v3/models.js.map +1 -1
  113. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  114. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  115. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  116. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  117. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  118. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  119. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  120. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  121. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  122. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  123. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  124. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  125. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  126. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  127. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  128. package/dist/storage/storage-index.d.ts +4 -4
  129. package/dist/storage/storage-index.js +4 -4
  130. package/dist/storage/storage-index.js.map +1 -1
  131. package/dist/types/types.d.ts +49 -3
  132. package/dist/types/types.js +48 -1
  133. package/dist/types/types.js.map +1 -1
  134. package/dist/utils/test-utils.d.ts +1 -2
  135. package/dist/utils/test-utils.js +7 -1
  136. package/dist/utils/test-utils.js.map +1 -1
  137. package/dist/utils/util.d.ts +9 -2
  138. package/dist/utils/util.js +128 -4
  139. package/dist/utils/util.js.map +1 -1
  140. package/package.json +12 -19
  141. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  142. package/src/storage/MongoBucketStorage.ts +347 -88
  143. package/src/storage/implementation/MongoBucketBatch.ts +147 -82
  144. package/src/storage/implementation/MongoChecksums.ts +93 -153
  145. package/src/storage/implementation/MongoCompactor.ts +102 -316
  146. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  147. package/src/storage/implementation/MongoParameterCompactor.ts +25 -6
  148. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  149. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  150. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  151. package/src/storage/implementation/MongoStorageProvider.ts +46 -4
  152. package/src/storage/implementation/MongoSyncBucketStorage.ts +121 -87
  153. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  154. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +202 -28
  155. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +9 -0
  156. package/src/storage/implementation/common/PersistedBatch.ts +19 -3
  157. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  158. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  159. package/src/storage/implementation/db.ts +19 -20
  160. package/src/storage/implementation/models.ts +20 -14
  161. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +75 -37
  162. package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
  163. package/src/storage/implementation/v1/MongoCompactorV1.ts +294 -9
  164. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +142 -105
  165. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  166. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  167. package/src/storage/implementation/v1/models.ts +11 -1
  168. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  169. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +382 -321
  170. package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
  171. package/src/storage/implementation/v3/MongoCompactorV3.ts +847 -32
  172. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +482 -0
  173. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +359 -187
  174. package/src/storage/implementation/v3/PersistedBatchV3.ts +118 -37
  175. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  176. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +46 -36
  177. package/src/storage/implementation/v3/bucket-format.ts +97 -0
  178. package/src/storage/implementation/v3/chunking.ts +40 -0
  179. package/src/storage/implementation/v3/models.ts +132 -55
  180. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  181. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  182. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  183. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  184. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  185. package/src/storage/storage-index.ts +12 -4
  186. package/src/types/types.ts +58 -1
  187. package/src/utils/test-utils.ts +8 -3
  188. package/src/utils/util.ts +185 -6
  189. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  190. package/test/src/__snapshots__/storage_sync.test.ts.snap +35 -617
  191. package/test/src/cleanup-stopped-sync-configs.test.ts +671 -0
  192. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  193. package/test/src/helpers/s3TestFactory.ts +69 -0
  194. package/test/src/object_storage_lifecycle.test.ts +59 -0
  195. package/test/src/setup.ts +6 -1
  196. package/test/src/storage.test.ts +227 -13
  197. package/test/src/storage_compacting.test.ts +1951 -12
  198. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  199. package/test/src/storage_s3_checksums.test.ts +191 -0
  200. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  201. package/test/src/storage_s3_reading.test.ts +526 -0
  202. package/test/src/storage_s3_writing.test.ts +135 -0
  203. package/test/src/storage_sync.test.ts +1180 -73
  204. package/test/src/storeCurrentData.test.ts +227 -0
  205. package/test/src/util.ts +2 -1
  206. package/tsconfig.json +1 -4
  207. package/tsconfig.tsbuildinfo +1 -1
  208. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  209. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  210. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  211. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  212. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  213. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  214. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  215. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  216. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  217. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  218. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  219. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  220. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  221. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  222. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  223. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  224. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  225. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  226. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  227. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  228. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  229. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  230. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
  231. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  232. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  233. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  234. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  235. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  236. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  237. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  238. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
  239. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
@@ -1,11 +1,11 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { ColumnDescriptor, storage } from '@powersync/service-core';
4
- import { HydratedSyncConfig, MatchingSources } from '@powersync/service-sync-rules';
3
+ import { storage } from '@powersync/service-core';
5
4
  import * as bson from 'bson';
6
5
  import { mongoTableId } from '../../../utils/util.js';
7
- import { calculateCheckpointState } from '../CheckpointState.js';
6
+ import { canCheckpointState } from '../CheckpointState.js';
8
7
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
8
+ import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
9
9
  import { syncRuleStateUpdatePipeline } from '../SyncRuleStateUpdate.js';
10
10
  import { PersistedBatch } from '../common/PersistedBatch.js';
11
11
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
@@ -13,31 +13,39 @@ import { PersistedBatchV3 } from './PersistedBatchV3.js';
13
13
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
14
14
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
15
15
  import { ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
16
-
17
- function sameStringArray(left: string[], right: string[]) {
18
- return left.length == right.length && left.every((value, index) => value == right[index]);
19
- }
16
+ import {
17
+ createNewSourceTable,
18
+ designateEventCarrier,
19
+ overlappingSourceTableFilter,
20
+ planSourceTableReconciliation,
21
+ sourceTableDesiredResolution,
22
+ sourceTableFromDocument,
23
+ SourceTableReconciliationContext
24
+ } from './source-table-utils.js';
20
25
 
21
26
  export class MongoBucketBatchV3 extends MongoBucketBatch {
22
- declare public readonly db: VersionedPowerSyncMongoV3;
27
+ declare readonly db: VersionedPowerSyncMongoV3;
23
28
 
24
29
  private readonly store: SourceRecordStore;
25
- private readonly syncConfigId: bson.ObjectId;
30
+ private readonly syncConfigIds: bson.ObjectId[];
26
31
  private needsActivationV3 = true;
27
32
  private lastWaitingLogThrottledV3 = 0;
28
33
 
29
34
  constructor(options: MongoBucketBatchOptions) {
30
35
  super(options);
31
- if (options.syncConfigId == null) {
36
+ const syncConfigIds = options.syncConfigIds ?? [];
37
+ if (syncConfigIds.length == 0) {
32
38
  throw new ReplicationAssertionError('Missing sync config id for v3 batch');
33
39
  }
34
- this.syncConfigId = options.syncConfigId;
35
- this.store = new SourceRecordStoreV3(this.db, this.group_id, this.mapping);
40
+ this.syncConfigIds = syncConfigIds;
41
+ this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
36
42
  }
37
43
 
38
44
  protected createPersistedBatch(writtenSize: number): PersistedBatch {
39
- return new PersistedBatchV3(this.db, this.group_id, this.mapping, writtenSize, {
40
- logger: this.logger
45
+ return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
46
+ logger: this.logger,
47
+ objectStorage: this.options.objectStorage,
48
+ inlineThresholdBytes: this.options.inlineThresholdBytes
41
49
  });
42
50
  }
43
51
 
@@ -45,10 +53,83 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
45
53
  return this.store;
46
54
  }
47
55
 
56
+ protected override async onReplicationTransactionFlush(
57
+ session: lib_mongo.mongo.ClientSession,
58
+ lastOp: bigint
59
+ ): Promise<void> {
60
+ // Durably advance the stream-level head of persisted ops within the flush transaction.
61
+ // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
62
+ // config that replicates nothing) covers all ops persisted before a potential crash.
63
+ await this.db.sync_rules.updateOne(
64
+ {
65
+ _id: this.replicationStreamId
66
+ },
67
+ {
68
+ $max: {
69
+ last_persisted_op: lastOp
70
+ }
71
+ },
72
+ { session }
73
+ );
74
+ }
75
+
76
+ private selectedSyncConfigObjectIds(syncConfigIds: string[]): bson.ObjectId[] {
77
+ const selectedIds = new Set(this.syncConfigIds.map((id) => id.toHexString()));
78
+ return syncConfigIds
79
+ .filter((id) => selectedIds.has(id) && bson.ObjectId.isValid(id))
80
+ .map((id) => new bson.ObjectId(id));
81
+ }
82
+
83
+ private relevantSyncConfigIds(table: storage.SourceTable): bson.ObjectId[] {
84
+ const bucketDataSourceIds = table.bucketDataSources.map((source) => this.mapping.bucketSourceId(source));
85
+ const parameterLookupSourceIds = table.parameterLookupSources.map((source) =>
86
+ this.mapping.parameterLookupId(source)
87
+ );
88
+ return this.selectedSyncConfigObjectIds(
89
+ this.mapping.syncConfigIdsForSourceTable(
90
+ this.syncConfigIds.map((id) => id.toHexString()),
91
+ table.ref,
92
+ bucketDataSourceIds,
93
+ parameterLookupSourceIds
94
+ )
95
+ );
96
+ }
97
+
98
+ private relevantSyncConfigIdsForTables(tables: storage.SourceTable[]): bson.ObjectId[] {
99
+ const ids = new Map<string, bson.ObjectId>();
100
+ for (const table of tables) {
101
+ for (const id of this.relevantSyncConfigIds(table)) {
102
+ ids.set(id.toHexString(), id);
103
+ }
104
+ }
105
+ return [...ids.values()];
106
+ }
107
+
108
+ private snapshotBlockingSourceTablesFilter(): Record<string, unknown> {
109
+ const clauses = this.syncConfigIds.flatMap((syncConfigId) => {
110
+ const filter = this.mapping.snapshotBlockingSourceTablesFilter(syncConfigId.toHexString()) as {
111
+ $or?: Record<string, unknown>[];
112
+ };
113
+ return filter.$or ?? [];
114
+ });
115
+
116
+ if (clauses.length == 0) {
117
+ return {
118
+ snapshot_done: false,
119
+ _id: { $exists: false }
120
+ };
121
+ }
122
+
123
+ return {
124
+ snapshot_done: false,
125
+ $or: clauses
126
+ };
127
+ }
128
+
48
129
  protected async cleanupDroppedSourceTables(sourceTables: storage.SourceTable[]) {
49
130
  for (const table of sourceTables) {
50
131
  await this.db
51
- .sourceRecordsV3(this.group_id, mongoTableId(table.id))
132
+ .sourceRecords(this.replicationStreamId, mongoTableId(table.id))
52
133
  .drop()
53
134
  .catch((error) => {
54
135
  if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
@@ -60,235 +141,103 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
60
141
  }
61
142
 
62
143
  async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
63
- const ref = options.source;
64
- const syncRules = options.syncRules ?? this.sync_rules;
65
- const matchingSources = syncRules.getMatchingSources(ref);
144
+ // The test-only override is a whole parsed set, so the sync rules and the mapping
145
+ // used below always come from the same parse.
146
+ const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
147
+ const syncConfig = parsedOverride?.hydratedSyncConfig ?? this.sync_rules;
148
+ const mapping = parsedOverride?.mapping ?? this.mapping;
66
149
 
67
150
  const { connection_id, source } = options;
68
- const { schema, name, objectId, replicaIdColumns, connectionTag } = source;
69
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
70
- name: column.name,
71
- type: column.type,
72
- type_oid: column.typeId
73
- }));
151
+ const context: SourceTableReconciliationContext = {
152
+ connectionId: connection_id,
153
+ connectionTag: source.connectionTag,
154
+ identity: {
155
+ schema: source.schema,
156
+ name: source.name,
157
+ objectId: source.objectId,
158
+ replicaIdColumns: source.replicaIdColumns.map((column) => ({
159
+ name: column.name,
160
+ type: column.type,
161
+ type_oid: column.typeId
162
+ }))
163
+ },
164
+ storeCurrentData: source.sendsCompleteRows !== true,
165
+ syncConfig,
166
+ mapping,
167
+ desired: sourceTableDesiredResolution(syncConfig, source, mapping)
168
+ };
74
169
 
75
170
  let result: storage.ResolveTablesResult | null = null;
76
- const initializeSourceRecordsFor: bson.ObjectId[] = [];
77
-
78
- await this.db.client.withSession(async (session) => {
79
- const col = this.db.commonSourceTables(this.group_id);
80
- const exactFilter: Record<string, unknown> = {
81
- connection_id,
82
- schema_name: schema,
83
- table_name: name,
84
- replica_id_columns2: normalizedReplicaIdColumns
85
- };
86
- if (objectId != null) {
87
- exactFilter.relation_id = objectId;
88
- }
171
+ const session = this.db.client.startSession();
172
+ await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
89
173
 
90
- const exactDocs = (await col.find(exactFilter, { session }).toArray()) as SourceTableDocumentV3[];
91
- const bucketSourceById = new Map(
92
- matchingSources.bucketDataSources.map((source) => [this.mapping.bucketSourceId(source), source] as const)
93
- );
94
- const parameterLookupSourceById = new Map(
95
- matchingSources.parameterLookupSources.map(
96
- (source) => [this.mapping.parameterLookupId(source), source] as const
97
- )
98
- );
99
- const desiredBucketIds = new Set(bucketSourceById.keys());
100
- const desiredLookupIds = new Set(parameterLookupSourceById.keys());
101
- const desiredHasMembership = desiredBucketIds.size > 0 || desiredLookupIds.size > 0;
102
- const triggersEvent = syncRules.tableTriggersEvent(ref);
103
-
104
- const coveredBucketIds = new Set<string>();
105
- const coveredLookupIds = new Set<string>();
106
- const retainedDocIds: bson.ObjectId[] = [];
107
- const tables: storage.SourceTable[] = [];
108
- let retainedEventOnlyTable = false;
109
-
110
- for (const doc of exactDocs) {
111
- const bucketDataSourceIds = doc.bucket_data_source_ids.filter((id) => desiredBucketIds.has(id));
112
- const parameterLookupSourceIds = doc.parameter_lookup_source_ids.filter((id) => desiredLookupIds.has(id));
113
- const coversDesiredMembership = bucketDataSourceIds.length > 0 || parameterLookupSourceIds.length > 0;
114
- const coversEventOnlyTable = !desiredHasMembership && triggersEvent && !retainedEventOnlyTable;
115
-
116
- for (const id of bucketDataSourceIds) {
117
- coveredBucketIds.add(id);
118
- }
119
- for (const id of parameterLookupSourceIds) {
120
- coveredLookupIds.add(id);
121
- }
174
+ await session.withTransaction(async () => {
175
+ const col = this.db.sourceTables(this.replicationStreamId);
122
176
 
123
- if (
124
- !sameStringArray(doc.bucket_data_source_ids, bucketDataSourceIds) ||
125
- !sameStringArray(doc.parameter_lookup_source_ids, parameterLookupSourceIds)
126
- ) {
127
- await col.updateOne(
128
- { _id: doc._id },
129
- {
130
- $set: {
131
- bucket_data_source_ids: bucketDataSourceIds,
132
- parameter_lookup_source_ids: parameterLookupSourceIds
133
- }
134
- },
135
- { session }
136
- );
137
- }
177
+ // Fetch every persisted source-table doc that can overlap this physical table.
178
+ // Exact-identity docs are candidates for reuse; non-exact overlaps are possible drops.
179
+ const candidateDocs = await col
180
+ .find(overlappingSourceTableFilter(connection_id, context.identity), { session })
181
+ .toArray();
138
182
 
139
- if (coversDesiredMembership || coversEventOnlyTable) {
140
- if (coversEventOnlyTable) {
141
- retainedEventOnlyTable = true;
142
- }
143
- retainedDocIds.push(doc._id);
144
- tables.push(
145
- this.sourceTableFromDocument(
146
- {
147
- ...doc,
148
- bucket_data_source_ids: bucketDataSourceIds,
149
- parameter_lookup_source_ids: parameterLookupSourceIds
150
- },
151
- connectionTag,
152
- syncRules,
153
- {
154
- bucketDataSources: bucketDataSourceIds.map((id) => bucketSourceById.get(id)!),
155
- parameterLookupSources: parameterLookupSourceIds.map((id) => parameterLookupSourceById.get(id)!)
156
- }
157
- )
158
- );
159
- }
183
+ // Pure planning: which docs to retain (and narrow), whether a new doc is needed for
184
+ // uncovered memberships, and which docs conflict with the current identity.
185
+ const plan = planSourceTableReconciliation(candidateDocs, context);
186
+
187
+ // Persist narrowing for incomplete snapshots only. Snapshot-complete docs keep stale
188
+ // coverage ids so compatible future configs can reuse already-snapshotted data.
189
+ // Narrowing occurs after removing a sync config, meaning we don't process those
190
+ // definitions anymore.
191
+ for (const update of plan.narrowingUpdates) {
192
+ await col.updateOne(
193
+ { _id: update.id },
194
+ {
195
+ $set: {
196
+ bucket_data_source_ids: update.memberships.bucketDataSourceIds,
197
+ parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds
198
+ }
199
+ },
200
+ { session }
201
+ );
160
202
  }
161
203
 
162
- const uncoveredBucketIds = [...desiredBucketIds].filter((id) => !coveredBucketIds.has(id));
163
- const uncoveredLookupIds = [...desiredLookupIds].filter((id) => !coveredLookupIds.has(id));
164
-
165
- if (uncoveredBucketIds.length > 0 || uncoveredLookupIds.length > 0 || (triggersEvent && tables.length == 0)) {
204
+ // Any desired membership not covered by an existing doc gets a new source table.
205
+ // That table snapshots only the uncovered memberships.
206
+ if (plan.newTableMemberships != null) {
166
207
  const id = options.idGenerator ? (options.idGenerator() as bson.ObjectId) : new bson.ObjectId();
167
- const sourceTable = new storage.SourceTable({
168
- id,
169
- ref,
170
- objectId,
171
- replicaIdColumns,
172
- snapshotComplete: false,
173
- bucketDataSources: uncoveredBucketIds.map((id) => bucketSourceById.get(id)!),
174
- parameterLookupSources: uncoveredLookupIds.map((id) => parameterLookupSourceById.get(id)!)
175
- });
176
- sourceTable.syncData = uncoveredBucketIds.length > 0;
177
- sourceTable.syncParameters = uncoveredLookupIds.length > 0;
178
- sourceTable.syncEvent = triggersEvent;
179
-
180
- const createDoc: SourceTableDocumentV3 = {
181
- _id: id,
182
- connection_id,
183
- relation_id: objectId,
184
- schema_name: schema,
185
- table_name: name,
186
- replica_id_columns: null,
187
- replica_id_columns2: normalizedReplicaIdColumns,
188
- snapshot_done: false,
189
- snapshot_status: undefined,
190
- bucket_data_source_ids: uncoveredBucketIds,
191
- parameter_lookup_source_ids: uncoveredLookupIds
192
- };
208
+ const { doc, table } = createNewSourceTable(id, plan.newTableMemberships, context);
193
209
 
194
- await col.insertOne(createDoc, { session });
195
- initializeSourceRecordsFor.push(createDoc._id);
196
- retainedDocIds.push(createDoc._id);
197
- tables.push(sourceTable);
210
+ await col.insertOne(doc, { session });
211
+ await this.db.initializeSourceRecordsCollection(this.replicationStreamId, doc._id, session);
212
+ plan.tables.push(table);
198
213
  }
199
214
 
200
- const conflictFilter = [{ schema_name: schema, table_name: name }] as Record<string, unknown>[];
201
- if (objectId != null) {
202
- conflictFilter.push({ relation_id: objectId });
203
- }
204
- const dropTables = await col
205
- .find(
206
- {
207
- connection_id,
208
- _id: { $nin: retainedDocIds },
209
- $or: conflictFilter
210
- },
211
- { session }
212
- )
213
- .toArray();
215
+ // If memberships are split across multiple source tables, only one may fire events.
216
+ designateEventCarrier(plan.tables, context.desired.triggersEvent);
214
217
 
215
218
  result = {
216
- tables,
217
- dropTables: dropTables.map((doc) =>
218
- this.sourceTableFromDocument(doc as SourceTableDocumentV3, connectionTag, syncRules)
219
- )
219
+ tables: plan.tables,
220
+ dropTables: plan.dropDocs.map((doc) => sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping))
220
221
  };
221
222
  });
222
223
 
223
- for (const sourceTableId of initializeSourceRecordsFor) {
224
- await this.db.initializeSourceRecordsCollection(this.group_id, sourceTableId);
225
- }
226
-
227
224
  return result!;
228
225
  }
229
226
 
230
- private sourceTableFromDocument(
231
- doc: SourceTableDocumentV3,
232
- connectionTag: string,
233
- syncRules: HydratedSyncConfig,
234
- memberships?: MatchingSources
235
- ): storage.SourceTable {
236
- const resolvedMemberships = memberships ?? this.sourceTableMembershipsFromDocument(doc, syncRules);
237
- const table = new storage.SourceTable({
238
- id: doc._id,
239
- ref: {
240
- connectionTag,
241
- schema: doc.schema_name,
242
- name: doc.table_name
243
- },
244
- objectId: doc.relation_id,
245
- replicaIdColumns: doc.replica_id_columns2!.map(
246
- (c) => ({ name: c.name, typeId: c.type_oid, type: c.type }) satisfies ColumnDescriptor
247
- ),
248
- snapshotComplete: doc.snapshot_done ?? true,
249
- bucketDataSources: resolvedMemberships.bucketDataSources,
250
- parameterLookupSources: resolvedMemberships.parameterLookupSources
251
- });
252
- table.syncData = table.bucketDataSources.length > 0;
253
- table.syncParameters = table.parameterLookupSources.length > 0;
254
- table.syncEvent = syncRules.tableTriggersEvent(table.ref);
255
- table.snapshotStatus =
256
- doc.snapshot_status == null
257
- ? undefined
258
- : {
259
- lastKey: doc.snapshot_status.last_key?.buffer ?? null,
260
- totalEstimatedCount: doc.snapshot_status.total_estimated_count,
261
- replicatedCount: doc.snapshot_status.replicated_count
262
- };
263
- return table;
264
- }
265
-
266
- private sourceTableMembershipsFromDocument(
267
- doc: SourceTableDocumentV3,
268
- syncRules: HydratedSyncConfig
269
- ): MatchingSources {
270
- const bucketDataSourceIds = new Set(doc.bucket_data_source_ids);
271
- const parameterLookupSourceIds = new Set(doc.parameter_lookup_source_ids);
272
-
273
- return {
274
- bucketDataSources: syncRules.bucketDataSources.filter((source) =>
275
- bucketDataSourceIds.has(this.mapping.bucketSourceId(source))
276
- ),
277
- parameterLookupSources: syncRules.bucketParameterLookupSources.filter((source) =>
278
- parameterLookupSourceIds.has(this.mapping.parameterLookupId(source))
279
- )
280
- };
281
- }
282
-
283
227
  async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
284
228
  const doc = (await this.db
285
- .commonSourceTables(this.group_id)
229
+ .sourceTables(this.replicationStreamId)
286
230
  .findOne({ _id: mongoTableId(table.id) }, { session: this.session })) as SourceTableDocumentV3 | null;
287
231
  if (doc == null) {
288
232
  return null;
289
233
  }
290
234
 
291
- return this.sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules);
235
+ const refreshed = sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules, this.mapping);
236
+ // The event-carrier designation is decided per resolveTables result and not persisted -
237
+ // preserve the caller's designation instead of recomputing it from the ref, so that
238
+ // refreshing a non-carrier table does not make it fire events.
239
+ refreshed.syncEvent = table.syncEvent;
240
+ return refreshed;
292
241
  }
293
242
 
294
243
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
@@ -296,6 +245,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
296
245
 
297
246
  await this.flush(options);
298
247
 
248
+ using _ = this.tracer.span('storage', 'commit');
249
+
299
250
  const now = new Date();
300
251
 
301
252
  await this.db.write_checkpoints.updateMany(
@@ -313,97 +264,156 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
313
264
  }
314
265
  );
315
266
 
316
- const preUpdateDocument = await this.db.sync_rules.findOne(
267
+ const preUpdateDocument = (await this.db.sync_rules.findOne(
317
268
  {
318
- _id: this.group_id,
319
- 'sync_configs._id': this.syncConfigId
269
+ _id: this.replicationStreamId,
270
+ 'sync_configs._id': { $in: this.syncConfigIds }
320
271
  },
321
272
  {
322
273
  session: this.session,
323
274
  projection: {
324
- snapshot_lsn: 1,
325
- sync_configs: {
326
- $elemMatch: {
327
- _id: this.syncConfigId
328
- }
329
- }
275
+ sync_configs: 1,
276
+ last_persisted_op: 1
330
277
  }
331
278
  }
332
- );
279
+ )) as ReplicationStreamDocumentV3 | null;
333
280
 
334
- const state = (preUpdateDocument as ReplicationStreamDocumentV3)?.sync_configs?.[0];
335
- if (state == null) {
281
+ const states =
282
+ preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
283
+ if (states.length == 0) {
336
284
  throw new ReplicationAssertionError(
337
- `Failed to update checkpoint - no matching sync_config for _id: ${this.group_id}/${this.syncConfigId.toHexString()}`
285
+ `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
286
+ .map((id) => id.toHexString())
287
+ .join(',')}`
338
288
  );
339
289
  }
290
+ // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
291
+ // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
292
+ // remain embedded in the document and are ignored here.
293
+ const missingSyncConfig = preUpdateDocument!.sync_configs.find(
294
+ (config) =>
295
+ [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
296
+ !this.syncConfigIds.some((id) => id.equals(config._id))
297
+ );
298
+ if (missingSyncConfig != null) {
299
+ throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
300
+ }
340
301
 
341
- const checkpointState = calculateCheckpointState({
342
- lsn,
343
- snapshotDone: state.snapshot_done === true,
344
- lastCheckpointLsn: state.last_checkpoint_lsn,
345
- noCheckpointBefore: state.no_checkpoint_before,
346
- keepaliveOp: state.keepalive_op == null ? null : BigInt(state.keepalive_op),
347
- lastCheckpoint: state.last_checkpoint,
348
- persistedOp: this.persisted_op,
349
- createEmptyCheckpoints
350
- });
302
+ // Effective head of the stream's op sequence.
303
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
304
+ // read uses the same session, so it covers all ops persisted by this batch.
305
+ const newCheckpoint =
306
+ preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
307
+
308
+ let checkpointBlocked = false;
309
+ let checkpointCreated = false;
310
+ let checkpointLogState: unknown = null;
311
+ const unblockedConfigIds: bson.ObjectId[] = [];
312
+
313
+ for (const state of states) {
314
+ if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
315
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
316
+ // checkpoints are only ever created at that head, so a checkpoint past the head means the
317
+ // op sequence or the stored state is corrupt.
318
+ throw new ReplicationAssertionError(
319
+ `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
320
+ );
321
+ }
351
322
 
352
- const updateSet: Record<string, any> = {
353
- last_keepalive_ts: now,
354
- last_fatal_error: null,
355
- last_fatal_error_ts: null,
356
- 'sync_configs.$[config].keepalive_op': checkpointState.newKeepaliveOp,
357
- 'sync_configs.$[config].last_checkpoint': checkpointState.newLastCheckpoint
358
- };
359
- if (checkpointState.checkpointCreated) {
360
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
361
- updateSet['snapshot_lsn'] = null;
362
- updateSet['last_checkpoint_ts'] = now;
323
+ const canCheckpoint = canCheckpointState(lsn, {
324
+ snapshotDone: state.snapshot_done === true,
325
+ lastCheckpointLsn: state.last_checkpoint_lsn,
326
+ noCheckpointBefore: state.no_checkpoint_before
327
+ });
328
+
329
+ if (!canCheckpoint) {
330
+ checkpointBlocked = true;
331
+ // Log the first blocked config's state.
332
+ checkpointLogState ??= {
333
+ snapshot_done: state.snapshot_done,
334
+ last_checkpoint_lsn: state.last_checkpoint_lsn,
335
+ no_checkpoint_before: state.no_checkpoint_before
336
+ };
337
+ continue;
338
+ }
339
+
340
+ checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
341
+ unblockedConfigIds.push(state._id);
363
342
  }
364
343
 
365
- await this.db.sync_rules.updateOne(
366
- {
367
- _id: this.group_id,
368
- 'sync_configs._id': this.syncConfigId
369
- },
370
- {
371
- $set: updateSet
372
- },
373
- {
374
- session: this.session,
375
- arrayFilters: [{ 'config._id': this.syncConfigId }]
344
+ // Every commit advances the stream's resume position: commit() flushes first, so all
345
+ // source changes up to this lsn have been persisted, even when checkpoints are blocked.
346
+ // In the future we could also advance this on flush, when the connector provides the
347
+ // current position (see setResumeLsn, which connectors may already call after flushing).
348
+ const resumeLsnUpdate = { resume_lsn: lsn };
349
+
350
+ if (unblockedConfigIds.length > 0) {
351
+ // All unblocked configs get the SAME new value, so we apply it with a single updateOne
352
+ // (single-document atomicity).
353
+ const updateSet: Record<string, any> = {
354
+ last_keepalive_ts: now,
355
+ last_fatal_error: null,
356
+ last_fatal_error_ts: null
357
+ };
358
+ // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
359
+ // the previous per-config / v1 behaviour.
360
+ if (checkpointCreated) {
361
+ updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
362
+ updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
363
+ updateSet['last_checkpoint_ts'] = now;
376
364
  }
377
- );
378
365
 
379
- if (checkpointState.checkpointBlocked) {
366
+ await this.db.sync_rules.updateOne(
367
+ {
368
+ _id: this.replicationStreamId,
369
+ 'sync_configs._id': { $in: unblockedConfigIds }
370
+ },
371
+ { $set: updateSet, $max: resumeLsnUpdate },
372
+ {
373
+ session: this.session,
374
+ arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
375
+ }
376
+ );
377
+ } else {
378
+ // All selected configs are blocked - only update keepalive/error tracking and the
379
+ // resume position.
380
+ await this.db.sync_rules.updateOne(
381
+ {
382
+ _id: this.replicationStreamId
383
+ },
384
+ {
385
+ $set: {
386
+ last_keepalive_ts: now,
387
+ last_fatal_error: null,
388
+ last_fatal_error_ts: null
389
+ },
390
+ $max: resumeLsnUpdate
391
+ },
392
+ { session: this.session }
393
+ );
394
+ }
395
+
396
+ if (checkpointBlocked) {
380
397
  if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
381
398
  this.logger.info(
382
- `Waiting before creating checkpoint, currently at ${lsn} / ${checkpointState.newKeepaliveOp}. Current state: ${JSON.stringify(
383
- {
384
- snapshot_done: state.snapshot_done,
385
- last_checkpoint_lsn: state.last_checkpoint_lsn,
386
- no_checkpoint_before: state.no_checkpoint_before
387
- }
388
- )}`
399
+ `Waiting before creating checkpoint, currently at ${lsn}. Current state: ${JSON.stringify(checkpointLogState)}`
389
400
  );
390
401
  this.lastWaitingLogThrottledV3 = Date.now();
391
402
  }
392
403
  } else {
393
- if (checkpointState.checkpointCreated) {
394
- this.logger.debug(`Created checkpoint at ${lsn} / ${checkpointState.newLastCheckpoint}`);
404
+ if (checkpointCreated) {
405
+ this.logger.debug(`Created checkpoint at ${lsn}`);
395
406
  }
396
407
  await this.autoActivateV3(lsn);
408
+ // All configs are now checkpointed at newCheckpoint (the stream head).
409
+ await this.sourceRecordStore.postCommitCleanup(newCheckpoint, this.logger);
410
+ }
411
+ if (checkpointCreated) {
397
412
  await this.db.notifyCheckpoint();
398
- this.persisted_op = null;
399
- this.last_checkpoint_lsn = lsn;
400
- if (checkpointState.newLastCheckpoint != null) {
401
- await this.sourceRecordStore.postCommitCleanup(checkpointState.newLastCheckpoint, this.logger);
402
- }
403
413
  }
404
414
  return {
405
- checkpointBlocked: checkpointState.checkpointBlocked,
406
- checkpointCreated: checkpointState.checkpointCreated
415
+ checkpointBlocked,
416
+ checkpointCreated
407
417
  };
408
418
  }
409
419
 
@@ -412,17 +422,23 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
412
422
  }
413
423
 
414
424
  async setResumeLsn(lsn: string): Promise<void> {
425
+ using _ = this.tracer.span('storage', 'set_resume_lsn');
415
426
  await this.db.sync_rules.updateOne(
416
427
  {
417
- _id: this.group_id,
418
- 'sync_configs._id': this.syncConfigId
428
+ _id: this.replicationStreamId
419
429
  },
420
430
  {
421
431
  $set: {
422
- snapshot_lsn: lsn
432
+ resume_lsn: lsn
423
433
  }
424
434
  },
425
- { session: this.session }
435
+ {
436
+ session: this.session,
437
+ // Losing occasional resume LSN is fine. That may mean reprocessing
438
+ // some source changes in some edge cases, which is not an issue since
439
+ // changes are processed in an idempotent way.
440
+ writeConcern: { w: 1 }
441
+ }
426
442
  );
427
443
  }
428
444
 
@@ -433,36 +449,43 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
433
449
 
434
450
  const session = this.session;
435
451
  let activated = false;
452
+ let needsFutureActivationCheck = true;
436
453
  await session.withTransaction(async () => {
454
+ // Reset on transaction retries.
455
+ needsFutureActivationCheck = true;
456
+ activated = false;
457
+
437
458
  const doc = await this.db.sync_rules.findOne(
438
459
  {
439
- _id: this.group_id,
440
- 'sync_configs._id': this.syncConfigId
460
+ _id: this.replicationStreamId,
461
+ 'sync_configs._id': { $in: this.syncConfigIds }
441
462
  },
442
463
  {
443
464
  session,
444
465
  projection: {
445
466
  state: 1,
446
- sync_configs: {
447
- $elemMatch: {
448
- _id: this.syncConfigId
449
- }
450
- }
467
+ sync_configs: 1
451
468
  }
452
469
  }
453
470
  );
454
- const state = (doc as ReplicationStreamDocumentV3)?.sync_configs?.[0];
471
+ const states =
472
+ (doc as ReplicationStreamDocumentV3)?.sync_configs?.filter((config) =>
473
+ this.syncConfigIds.some((id) => id.equals(config._id))
474
+ ) ?? [];
475
+ if (doc == null || states.length == 0) {
476
+ return;
477
+ }
478
+
479
+ const processingStates = states.filter((state) => state.state == storage.SyncRuleState.PROCESSING);
455
480
  if (
456
- doc &&
457
481
  doc.state == storage.SyncRuleState.PROCESSING &&
458
- state?.state == storage.SyncRuleState.PROCESSING &&
459
- state.snapshot_done &&
460
- state.last_checkpoint != null
482
+ processingStates.length == states.length &&
483
+ states.every((state) => state.snapshot_done && state.last_checkpoint != null)
461
484
  ) {
462
485
  await this.db.sync_rules.updateOne(
463
486
  {
464
- _id: this.group_id,
465
- 'sync_configs._id': this.syncConfigId
487
+ _id: this.replicationStreamId,
488
+ 'sync_configs._id': { $in: this.syncConfigIds }
466
489
  },
467
490
  {
468
491
  $set: {
@@ -472,35 +495,64 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
472
495
  },
473
496
  {
474
497
  session,
475
- arrayFilters: [{ 'config._id': this.syncConfigId }]
498
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
476
499
  }
477
500
  );
478
501
 
479
502
  await this.db.sync_rules.updateMany(
480
503
  {
481
- _id: { $ne: this.group_id },
504
+ _id: { $ne: this.replicationStreamId },
482
505
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
483
506
  },
484
507
  syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
485
508
  { session }
486
509
  );
487
510
  activated = true;
488
- } else if (doc?.state != storage.SyncRuleState.PROCESSING) {
489
- this.needsActivationV3 = false;
511
+ } else if (
512
+ doc.state == storage.SyncRuleState.ACTIVE &&
513
+ processingStates.length > 0 &&
514
+ processingStates.every((state) => state.snapshot_done && state.last_checkpoint != null)
515
+ ) {
516
+ await this.db.sync_rules.updateOne(
517
+ {
518
+ _id: this.replicationStreamId,
519
+ 'sync_configs._id': { $in: processingStates.map((state) => state._id) }
520
+ },
521
+ {
522
+ $set: {
523
+ 'sync_configs.$[activeConfig].state': storage.SyncRuleState.STOP,
524
+ 'sync_configs.$[processingConfig].state': storage.SyncRuleState.ACTIVE
525
+ }
526
+ },
527
+ {
528
+ session,
529
+ arrayFilters: [
530
+ { 'activeConfig.state': storage.SyncRuleState.ACTIVE },
531
+ { 'processingConfig._id': { $in: processingStates.map((state) => state._id) } }
532
+ ]
533
+ }
534
+ );
535
+ activated = true;
536
+ } else if (doc.state != storage.SyncRuleState.PROCESSING && doc.state != storage.SyncRuleState.ACTIVE) {
537
+ needsFutureActivationCheck = false;
538
+ } else if (doc.state == storage.SyncRuleState.ACTIVE && processingStates.length == 0) {
539
+ needsFutureActivationCheck = false;
490
540
  }
491
541
  });
492
542
  if (activated) {
493
543
  this.logger.info(`Activated new replication stream at ${lsn}`);
494
544
  await this.db.notifyCheckpoint();
495
545
  this.needsActivationV3 = false;
546
+ } else if (!needsFutureActivationCheck) {
547
+ this.needsActivationV3 = false;
496
548
  }
497
549
  }
498
550
 
499
551
  async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
500
552
  await this.db.sync_rules.updateOne(
501
553
  {
502
- _id: this.group_id,
503
- 'sync_configs._id': this.syncConfigId
554
+ _id: this.replicationStreamId,
555
+ 'sync_configs._id': { $in: this.syncConfigIds }
504
556
  },
505
557
  {
506
558
  $set: {
@@ -513,7 +565,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
513
565
  },
514
566
  {
515
567
  session: this.session,
516
- arrayFilters: [{ 'config._id': this.syncConfigId }]
568
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
517
569
  }
518
570
  );
519
571
  }
@@ -521,16 +573,18 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
521
573
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
522
574
  await this.withTransaction(async () => {
523
575
  // Protect against race conditions
524
- const count = await this.db.sourceTablesV3(this.group_id).countDocuments(
525
- {
526
- snapshot_done: false
527
- },
528
- { session: this.session }
529
- );
530
- if (count > 0) {
576
+ const blockingTables = await this.db
577
+ .sourceTables(this.replicationStreamId)
578
+ .find(this.snapshotBlockingSourceTablesFilter(), {
579
+ session: this.session,
580
+ projection: { schema_name: 1, table_name: 1 }
581
+ })
582
+ .toArray();
583
+
584
+ if (blockingTables.length > 0) {
531
585
  if (options?.throwOnConflict ?? true) {
532
586
  throw new ReplicationAssertionError(
533
- `Cannot mark snapshot done while ${count} source table${count == 1 ? '' : 's'} still require snapshotting`
587
+ `Cannot mark snapshot done while source tables still require snapshotting. Tables: ${blockingTables.map((t) => `${t.schema_name}.${t.table_name}`).join(', ')}`
534
588
  );
535
589
  } else {
536
590
  return;
@@ -541,11 +595,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
541
595
  });
542
596
  }
543
597
 
544
- async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
598
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
599
+ const syncConfigIds = this.relevantSyncConfigIds(table);
600
+ if (syncConfigIds.length == 0) {
601
+ return;
602
+ }
603
+
545
604
  await this.db.sync_rules.updateOne(
546
605
  {
547
- _id: this.group_id,
548
- 'sync_configs._id': this.syncConfigId
606
+ _id: this.replicationStreamId,
607
+ 'sync_configs._id': { $in: syncConfigIds }
549
608
  },
550
609
  {
551
610
  $set: {
@@ -554,7 +613,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
554
613
  },
555
614
  {
556
615
  session: this.session,
557
- arrayFilters: [{ 'config._id': this.syncConfigId }]
616
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
558
617
  }
559
618
  );
560
619
  }
@@ -565,9 +624,10 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
565
624
  ): Promise<storage.SourceTable[]> {
566
625
  const session = this.session;
567
626
  const ids = tables.map((table) => mongoTableId(table.id));
627
+ const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
568
628
 
569
629
  await this.withTransaction(async () => {
570
- await this.db.commonSourceTables(this.group_id).updateMany(
630
+ await this.db.sourceTables(this.replicationStreamId).updateMany(
571
631
  { _id: { $in: ids } },
572
632
  {
573
633
  $set: {
@@ -580,11 +640,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
580
640
  { session }
581
641
  );
582
642
 
583
- if (no_checkpoint_before_lsn != null) {
643
+ if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
584
644
  await this.db.sync_rules.updateOne(
585
645
  {
586
- _id: this.group_id,
587
- 'sync_configs._id': this.syncConfigId
646
+ _id: this.replicationStreamId,
647
+ 'sync_configs._id': { $in: syncConfigIds }
588
648
  },
589
649
  {
590
650
  $set: {
@@ -596,7 +656,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
596
656
  },
597
657
  {
598
658
  session: this.session,
599
- arrayFilters: [{ 'config._id': this.syncConfigId }]
659
+ // Only set for sync configs that use this table
660
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
600
661
  }
601
662
  );
602
663
  }