@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
@@ -2,8 +2,14 @@ import { BucketDataDoc } from '@module/storage/implementation/common/BucketDataD
2
2
  import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
3
3
  import { loadBucketDataDocument, serializeBucketData } from '@module/storage/implementation/v3/bucket-format.js';
4
4
  import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
5
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from '@module/storage/implementation/v3/compaction-constants.js';
6
+ import { CompactionLease } from '@module/storage/implementation/v3/CompactionLease.js';
5
7
  import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
8
+ import { ObjectStorageError } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
6
9
  import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
10
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
11
+ import { replicaIdToSubkey } from '@module/utils/util.js';
12
+ import { logger as defaultLogger } from '@powersync/lib-services-framework';
7
13
  import {
8
14
  addChecksums,
9
15
  CheckpointChecksumInvalidatedError,
@@ -11,10 +17,39 @@ import {
11
17
  SyncRulesBucketStorage,
12
18
  updateSyncRulesFromYaml
13
19
  } from '@powersync/service-core';
14
- import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
20
+ import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
15
21
  import * as bson from 'bson';
16
- import { describe, expect, test } from 'vitest';
17
- import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
22
+ import { describe, expect, test, vi } from 'vitest';
23
+ import { env } from './env.js';
24
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
25
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
26
+
27
+ function makeOp(
28
+ opId: number,
29
+ rowId: string,
30
+ data: string,
31
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
32
+ sourceTableId: bson.ObjectId,
33
+ overrides?: { op?: 'PUT' | 'REMOVE' }
34
+ ): BucketDataDoc {
35
+ const sourceKey = test_utils.rid(rowId);
36
+ return {
37
+ bucketKey: {
38
+ replicationStreamId: ctx.replicationStreamId,
39
+ definitionId: ctx.definitionId,
40
+ bucket: ctx.bucket
41
+ },
42
+ o: BigInt(opId),
43
+ op: overrides?.op ?? 'PUT',
44
+ source_table: sourceTableId,
45
+ source_key: sourceKey,
46
+ subkey: replicaIdToSubkey(sourceTableId, sourceKey),
47
+ table: 'items',
48
+ row_id: rowId,
49
+ checksum: BigInt(opId * 7),
50
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
51
+ };
52
+ }
18
53
 
19
54
  describe('Mongo Sync Bucket Storage Compact', () => {
20
55
  register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
@@ -58,15 +93,18 @@ describe('Mongo Sync Bucket Storage Compact', () => {
58
93
  return bucketStorage.getCheckpoint();
59
94
  };
60
95
 
61
- const setup = async () => {
96
+ const setup = async (storageVersion?: number) => {
62
97
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
63
98
  const syncRules = await factory.updateSyncRules(
64
- updateSyncRulesFromYaml(`
99
+ updateSyncRulesFromYaml(
100
+ `
65
101
  bucket_definitions:
66
102
  by_user:
67
103
  parameters: select request.user_id() as user_id
68
104
  data: [select * from test where owner_id = bucket.user_id]
69
- `)
105
+ `,
106
+ { storageVersion }
107
+ )
70
108
  );
71
109
  const bucketStorage = factory.getInstance(syncRules);
72
110
  const syncRulesContent = syncRules.syncConfigContent[0];
@@ -75,20 +113,17 @@ bucket_definitions:
75
113
  return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
76
114
  };
77
115
 
78
- test('full compact', async () => {
116
+ test('V1 full compact with blank bucket_state', async () => {
79
117
  const { bucketStorage, checkpoint, factory, syncRules } = await setup();
80
118
  const storageDb = bucketStorage.db;
81
119
 
82
- // Simulate bucket_state from old version not being available
83
120
  if (storageDb.storageConfig.incrementalReprocessing) {
84
- // This should actually never happen on V3, but we test this anyway.
85
- // Can remove this if it causes issues in the future.
86
- await (storageDb as VersionedPowerSyncMongoV3).bucketState(bucketStorage.replicationStreamId).deleteMany({});
87
- } else {
88
- await factory.db.bucket_state.deleteMany({});
121
+ return;
89
122
  }
123
+ // Simulate a V1 deployment which pre-dates bucket-state population.
124
+ await factory.db.bucket_state.deleteMany({});
90
125
 
91
- await bucketStorage.compact({
126
+ await compactActive(factory, {
92
127
  clearBatchLimit: 200,
93
128
  moveBatchLimit: 10,
94
129
  moveBatchQueryLimit: 10,
@@ -113,18 +148,21 @@ bucket_definitions:
113
148
  });
114
149
  });
115
150
 
116
- test('populatePersistentChecksumCache', async () => {
151
+ test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
117
152
  // Populate old replication stream
118
- const { factory } = await setup();
153
+ const { factory } = await setup(storageVersion);
119
154
 
120
155
  // Now populate another replication stream (bucket definition name changed)
121
156
  const syncRules = await factory.updateSyncRules(
122
- updateSyncRulesFromYaml(`
157
+ updateSyncRulesFromYaml(
158
+ `
123
159
  bucket_definitions:
124
160
  by_user2:
125
161
  parameters: select request.user_id() as user_id
126
162
  data: [select * from test where owner_id = bucket.user_id]
127
- `)
163
+ `,
164
+ { storageVersion }
165
+ )
128
166
  );
129
167
  const bucketStorage = factory.getInstance(syncRules);
130
168
  const syncRulesContent = syncRules.syncConfigContent[0];
@@ -132,21 +170,23 @@ bucket_definitions:
132
170
  await populate(bucketStorage, 2);
133
171
  const { checkpoint } = await bucketStorage.getCheckpoint();
134
172
 
135
- // Default is to small small numbers - should be a no-op
136
- const result0 = await bucketStorage.populatePersistentChecksumCache({
173
+ // V3's initial lite pass processes every bucket with no prior compact state.
174
+ // Earlier storage versions use the default minimum-change threshold.
175
+ const result0 = await bucketStorage.compactInitialReplication({
137
176
  maxOpId: checkpoint
138
177
  });
139
- expect(result0.buckets).toEqual(0);
178
+ expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
140
179
 
141
- // This should cache the checksums for the two buckets
142
- const result1 = await bucketStorage.populatePersistentChecksumCache({
180
+ // For V1/V2, lower the threshold to populate the checksum cache. V3 has
181
+ // already updated its compacted state, so another initial pass is a no-op.
182
+ const result1 = await bucketStorage.compactInitialReplication({
143
183
  maxOpId: checkpoint,
144
184
  minBucketChanges: 1
145
185
  });
146
- expect(result1.buckets).toEqual(2);
186
+ expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
147
187
 
148
- // This should be a no-op, as the checksums are already cached
149
- const result2 = await bucketStorage.populatePersistentChecksumCache({
188
+ // Repeating it stays a no-op.
189
+ const result2 = await bucketStorage.compactInitialReplication({
150
190
  maxOpId: checkpoint,
151
191
  minBucketChanges: 1
152
192
  });
@@ -168,88 +208,82 @@ bucket_definitions:
168
208
  });
169
209
  });
170
210
 
171
- test('dirty bucket discovery handles bigint bucket_state bytes', async () => {
172
- await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
173
- const syncRules = await factory.updateSyncRules(
174
- updateSyncRulesFromYaml(`
175
- bucket_definitions:
176
- global:
177
- data: [select * from test]
178
- `)
179
- );
180
- const bucketStorage = factory.getInstance(syncRules);
181
- const storageDb = bucketStorage.db;
211
+ test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
212
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
213
+ const result = await (bucketStorage as MongoSyncBucketStorage)
214
+ .createMongoCompactor({
215
+ maxOpId: checkpoint,
216
+ compactChunksOnly: true,
217
+ minCompactChunkIntervalMs: 1
218
+ })
219
+ .compact();
220
+
221
+ expect(result).toBe(2);
222
+ });
182
223
 
183
- // This simulates bucket_state created using bigint bytes.
184
- // This typically happens when buckets get very large (> 2GiB). We don't want to create that much
185
- // data in the tests, so we directly insert the bucket_state here.
186
- if (storageDb.storageConfig.incrementalReprocessing) {
187
- const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
188
- bucketStorage.replicationStreamId
189
- );
190
- await bucketStateCollection.insertOne({
191
- _id: {
192
- d: '1',
193
- b: 'global[]'
194
- },
195
- last_op: 5n,
196
- compacted_state: {
197
- op_id: 3n,
198
- count: 3,
199
- checksum: 0n,
200
- bytes: 7n
201
- },
202
- estimate_since_compact: {
203
- count: 2,
204
- bytes: 5n
205
- }
206
- });
207
- } else {
208
- await factory.db.bucket_state.insertOne({
209
- _id: {
210
- g: bucketStorage.replicationStreamId,
211
- b: 'global[]'
212
- },
213
- last_op: 5n,
214
- compacted_state: {
215
- op_id: 3n,
216
- count: 3,
217
- checksum: 0n,
218
- bytes: 7n
219
- },
220
- estimate_since_compact: {
221
- count: 2,
222
- bytes: 5n
223
- }
224
- });
225
- }
224
+ test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
225
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
226
+ const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
227
+ expect(firstResult.buckets).toBe(2);
226
228
 
227
- // This test uses a couple of "internal" APIs of the compactor.
228
- const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n });
229
+ const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
230
+ bucketStorage.replicationStreamId
231
+ );
232
+ await bucketStateCollection.updateMany(
233
+ {},
234
+ {
235
+ $set: {
236
+ first_uncompacted_write: new Date(0),
237
+ next_compact_check: new Date(0)
238
+ }
239
+ }
240
+ );
229
241
 
230
- const dirtyBuckets = compactor.dirtyBucketBatches({
231
- minBucketChanges: 1,
232
- minChangeRatio: 0.39
242
+ const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
243
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
244
+ .toArray();
245
+ const result = await bucketStorage.compactInitialReplication({
246
+ maxOpId: checkpoint,
247
+ signal: AbortSignal.timeout(2_000)
233
248
  });
234
- const firstBatch = await dirtyBuckets.next();
235
249
 
236
- expect(firstBatch.done).toBe(false);
237
- expect(firstBatch.value).toHaveLength(1);
238
- expect(firstBatch.value[0].bucket).toBe('global[]');
239
- expect(firstBatch.value[0].estimatedCount).toBe(5);
240
- expect(typeof firstBatch.value[0].estimatedCount).toBe('number');
241
- expect(firstBatch.value[0].dirtyRatio).toBeCloseTo(5 / 12);
250
+ expect(result.buckets).toBe(0);
251
+ const states = await bucketStateCollection.find({}).toArray();
252
+ expect(states).toHaveLength(2);
253
+ for (const state of states) {
254
+ expect(state.next_compact_check!.getTime()).toBeGreaterThan(
255
+ now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
256
+ );
257
+ }
258
+ });
259
+
260
+ test('v3 replication writes initialize scheduled compaction state', async () => {
261
+ const { bucketStorage } = await setup();
262
+ const storageDb = bucketStorage.db;
242
263
 
243
- const checksumBuckets = await compactor.dirtyBucketBatchForChecksums({
244
- minBucketChanges: 1
245
- });
246
- expect(checksumBuckets).toEqual([
247
- {
248
- bucket: 'global[]',
249
- definitionId: storageDb.storageConfig.incrementalReprocessing ? '1' : null,
250
- estimatedCount: 5
251
- }
252
- ]);
264
+ if (!storageDb.storageConfig.incrementalReprocessing) {
265
+ return;
266
+ }
267
+ const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
268
+ bucketStorage.replicationStreamId
269
+ );
270
+ const states = await bucketStateCollection.find({}).toArray();
271
+ expect(states).toHaveLength(2);
272
+ for (const state of states) {
273
+ expect(state.last_op).toBeGreaterThan(0n);
274
+ expect(state.bucket_stats.count).toBe(1);
275
+ expect(state.bucket_stats.chunks).toBe(1);
276
+ expect(state.first_uncompacted_write).toBeInstanceOf(Date);
277
+ expect(state.next_compact_check).toBeInstanceOf(Date);
278
+ expect(state.compacted_state).toBeUndefined();
279
+ expect(state).not.toHaveProperty('estimate_since_compact');
280
+
281
+ const docs = await (storageDb as VersionedPowerSyncMongoV3)
282
+ .bucketData(bucketStorage.replicationStreamId, state._id.d)
283
+ .find({ '_id.b': state._id.b })
284
+ .toArray();
285
+ expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
286
+ }
253
287
  });
254
288
  });
255
289
  });
@@ -284,14 +318,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
284
318
  */
285
319
  describe('V3 invariant verification', () => {
286
320
  const BUCKET = 'global[]';
287
- const TABLE = 'items';
288
321
 
289
322
  function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
323
+ const sourceTable = new bson.ObjectId();
324
+ const sourceKey = 'key';
290
325
  return {
291
326
  bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
292
327
  op: 'PUT',
293
- source_table: new bson.ObjectId(),
294
- source_key: 'key',
328
+ source_table: sourceTable,
329
+ source_key: sourceKey,
330
+ subkey: replicaIdToSubkey(sourceTable, sourceKey),
295
331
  table: 'test',
296
332
  row_id: 'row1',
297
333
  checksum: 1n,
@@ -329,31 +365,6 @@ bucket_definitions:
329
365
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
330
366
  }
331
367
 
332
- function makeOp(
333
- opId: number,
334
- rowId: string,
335
- data: string,
336
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
337
- sourceTableId: bson.ObjectId,
338
- overrides?: { op?: 'PUT' | 'REMOVE' }
339
- ): BucketDataDoc {
340
- return {
341
- bucketKey: {
342
- replicationStreamId: ctx.replicationStreamId,
343
- definitionId: ctx.definitionId,
344
- bucket: ctx.bucket
345
- },
346
- o: BigInt(opId),
347
- op: overrides?.op ?? 'PUT',
348
- source_table: sourceTableId,
349
- source_key: test_utils.rid(rowId),
350
- table: TABLE,
351
- row_id: rowId,
352
- checksum: BigInt(opId * 7),
353
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
354
- };
355
- }
356
-
357
368
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
358
369
  await collection.insertMany(docs);
359
370
  }
@@ -362,7 +373,9 @@ bucket_definitions:
362
373
  await bucketStateCollection.insertOne({
363
374
  _id: { d: definitionId, b: BUCKET },
364
375
  last_op: lastOp,
365
- estimate_since_compact: { count: 10, bytes: 100 }
376
+ next_compact_check: new Date(0),
377
+ first_uncompacted_write: new Date(0),
378
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
366
379
  });
367
380
  }
368
381
 
@@ -371,12 +384,136 @@ bucket_definitions:
371
384
  clearBatchLimit: 200,
372
385
  moveBatchLimit: 10,
373
386
  moveBatchQueryLimit: 10,
374
- minBucketChanges: 1,
375
- minChangeRatio: 0,
376
387
  maxOpId
377
388
  });
378
389
  }
379
390
 
391
+ test('a successful lease renewal clears a transient renewal error', async () => {
392
+ const { bucketStateCollection, ctx } = await setupV3Storage();
393
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
394
+ const lease = await CompactionLease.claim(
395
+ bucketStateCollection,
396
+ { _id: { d: ctx.definitionId, b: BUCKET } },
397
+ undefined,
398
+ 10 * 60 * 1000
399
+ );
400
+ expect(lease).not.toBeNull();
401
+
402
+ try {
403
+ const transientError = new Error('temporary MongoDB error');
404
+ const renew = (lease as any).renew.bind(lease);
405
+ const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
406
+ await renew().catch((error: unknown) => {
407
+ (lease as any).renewalError = error;
408
+ });
409
+ await expect(lease!.throwIfLost()).rejects.toBe(transientError);
410
+
411
+ updateOne.mockRestore();
412
+ await renew();
413
+ await expect(lease!.throwIfLost()).resolves.toBeUndefined();
414
+ } finally {
415
+ await lease?.[Symbol.asyncDispose]();
416
+ }
417
+ });
418
+
419
+ test('a failed scheduled bucket does not block other scheduled buckets', async () => {
420
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
421
+ const badBucket = 'bad[]';
422
+ const goodBucket = 'good[]';
423
+ const goodDocument = serializeBucketData(goodBucket, [
424
+ makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
425
+ ]);
426
+ await insertDocs(collection, [goodDocument]);
427
+ await bucketStateCollection.insertMany([
428
+ {
429
+ _id: { d: ctx.definitionId, b: badBucket },
430
+ last_op: 2n,
431
+ next_compact_check: new Date(0),
432
+ // A malformed scheduled bucket with an expired lease must be
433
+ // rescheduled without preventing valid buckets from compacting.
434
+ first_uncompacted_write: new Date(0),
435
+ bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
436
+ compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
437
+ },
438
+ {
439
+ _id: { d: ctx.definitionId, b: goodBucket },
440
+ last_op: 2n,
441
+ next_compact_check: new Date(0),
442
+ first_uncompacted_write: new Date(0),
443
+ bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
444
+ }
445
+ ]);
446
+ await bucketStateCollection.updateOne(
447
+ { _id: { d: ctx.definitionId, b: badBucket } },
448
+ { $unset: { first_uncompacted_write: '' } }
449
+ );
450
+
451
+ await (bucketStorage as MongoSyncBucketStorage)
452
+ .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
453
+ .compact();
454
+
455
+ const [badState, goodState] = await Promise.all([
456
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
457
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
458
+ ]);
459
+ expect(badState?.next_compact_check).toBeInstanceOf(Date);
460
+ expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
461
+ expect(goodState?.compacted_state?.op_id).toBe(2n);
462
+ });
463
+
464
+ test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
465
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
466
+ const buckets = Array.from({ length: 8 }, (_, index) => `bucket${index}[]`);
467
+ const documents = buckets.map((bucket) =>
468
+ serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
469
+ );
470
+ await insertDocs(collection, documents);
471
+ await bucketStateCollection.insertMany(
472
+ documents.map((document, index) => ({
473
+ _id: { d: ctx.definitionId, b: buckets[index] },
474
+ last_op: 2n,
475
+ next_compact_check: new Date(0),
476
+ first_uncompacted_write: new Date(0),
477
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
478
+ }))
479
+ );
480
+
481
+ const abortController = new AbortController();
482
+ const testLogger = defaultLogger.child({});
483
+ vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
484
+ if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
485
+ abortController.abort();
486
+ }
487
+ return testLogger;
488
+ });
489
+ const errorLog = vi.spyOn(testLogger, 'error');
490
+
491
+ await expect(
492
+ bucketStorage
493
+ .createMongoCompactor({
494
+ maxOpId: 2n,
495
+ compactChunksOnly: true,
496
+ signal: abortController.signal,
497
+ logger: testLogger
498
+ })
499
+ .compact()
500
+ ).rejects.toThrow();
501
+
502
+ const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
503
+ const completed = states.filter((state) => state.compacted_state != null);
504
+ const remaining = states.filter((state) => state.compacted_state == null);
505
+ // Already-started buckets may finish after abort, but the queued remainder
506
+ // must not be claimed or rescheduled.
507
+ expect(completed.length).toBeGreaterThanOrEqual(1);
508
+ expect(completed.length).toBeLessThanOrEqual(2);
509
+ expect(remaining.length).toBeGreaterThanOrEqual(6);
510
+ for (const state of remaining) {
511
+ expect(state.next_compact_check).toEqual(new Date(0));
512
+ expect(state.compact_lease).toBeUndefined();
513
+ }
514
+ expect(errorLog).not.toHaveBeenCalled();
515
+ });
516
+
380
517
  test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
381
518
  const ops = [
382
519
  makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
@@ -746,7 +883,6 @@ bucket_definitions:
746
883
 
747
884
  describe('V3 checksum pipeline straddling', () => {
748
885
  const BUCKET = 'global[]';
749
- const TABLE = 'items';
750
886
 
751
887
  async function setup() {
752
888
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -776,30 +912,6 @@ bucket_definitions:
776
912
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
777
913
  }
778
914
 
779
- function makeOp(
780
- opId: number,
781
- rowId: string,
782
- data: string,
783
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
784
- sourceTableId: bson.ObjectId
785
- ): BucketDataDoc {
786
- return {
787
- bucketKey: {
788
- replicationStreamId: ctx.replicationStreamId,
789
- definitionId: ctx.definitionId,
790
- bucket: ctx.bucket
791
- },
792
- o: BigInt(opId),
793
- op: 'PUT',
794
- source_table: sourceTableId,
795
- source_key: test_utils.rid(rowId),
796
- table: TABLE,
797
- row_id: rowId,
798
- checksum: BigInt(opId * 7),
799
- data: JSON.stringify({ id: rowId, description: data })
800
- };
801
- }
802
-
803
915
  function checksumRequest(): storage.BucketChecksumRequest {
804
916
  return {
805
917
  bucket: BUCKET,
@@ -835,7 +947,9 @@ bucket_definitions:
835
947
  await bucketStateCollection.insertOne({
836
948
  _id: { d: definitionId, b: BUCKET },
837
949
  last_op: 30n,
838
- estimate_since_compact: { count: 3, bytes: 100 }
950
+ next_compact_check: undefined,
951
+ first_uncompacted_write: undefined,
952
+ bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
839
953
  });
840
954
 
841
955
  const request = checksumRequest();
@@ -874,7 +988,9 @@ bucket_definitions:
874
988
  await bucketStateCollection.insertOne({
875
989
  _id: { d: definitionId, b: BUCKET },
876
990
  last_op: 0n,
877
- estimate_since_compact: { count: 0, bytes: 0 }
991
+ next_compact_check: undefined,
992
+ first_uncompacted_write: undefined,
993
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
878
994
  });
879
995
 
880
996
  const request = checksumRequest();
@@ -894,7 +1010,9 @@ bucket_definitions:
894
1010
  await bucketStateCollection.insertOne({
895
1011
  _id: { d: definitionId, b: BUCKET },
896
1012
  last_op: 0n,
897
- estimate_since_compact: { count: 0, bytes: 0 }
1013
+ next_compact_check: undefined,
1014
+ first_uncompacted_write: undefined,
1015
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
898
1016
  });
899
1017
 
900
1018
  const request = checksumRequest();
@@ -913,7 +1031,9 @@ bucket_definitions:
913
1031
  await bucketStateCollection.insertOne({
914
1032
  _id: { d: definitionId, b: BUCKET },
915
1033
  last_op: 10n,
916
- estimate_since_compact: { count: 1, bytes: 100 }
1034
+ next_compact_check: undefined,
1035
+ first_uncompacted_write: undefined,
1036
+ bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
917
1037
  });
918
1038
 
919
1039
  const request = checksumRequest();
@@ -926,7 +1046,7 @@ bucket_definitions:
926
1046
  await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
927
1047
  await bucketStateCollection.updateOne(
928
1048
  { _id: { d: definitionId, b: BUCKET } },
929
- { $set: { last_op: 30n, 'estimate_since_compact.count': 2 } }
1049
+ { $set: { last_op: 30n, 'bucket_stats.count': 2 } }
930
1050
  );
931
1051
 
932
1052
  const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
@@ -940,7 +1060,6 @@ bucket_definitions:
940
1060
 
941
1061
  describe('V3 compaction boundaries', () => {
942
1062
  const BUCKET = 'global[]';
943
- const TABLE = 'items';
944
1063
 
945
1064
  async function setupV3() {
946
1065
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -971,31 +1090,6 @@ bucket_definitions:
971
1090
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
972
1091
  }
973
1092
 
974
- function makeOp(
975
- opId: number,
976
- rowId: string,
977
- data: string,
978
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
979
- sourceTableId: bson.ObjectId,
980
- overrides?: { op?: 'PUT' | 'REMOVE' }
981
- ): BucketDataDoc {
982
- return {
983
- bucketKey: {
984
- replicationStreamId: ctx.replicationStreamId,
985
- definitionId: ctx.definitionId,
986
- bucket: ctx.bucket
987
- },
988
- o: BigInt(opId),
989
- op: overrides?.op ?? 'PUT',
990
- source_table: sourceTableId,
991
- source_key: test_utils.rid(rowId),
992
- table: TABLE,
993
- row_id: rowId,
994
- checksum: BigInt(opId * 7),
995
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
996
- };
997
- }
998
-
999
1093
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1000
1094
  await collection.insertMany(docs);
1001
1095
  }
@@ -1004,7 +1098,9 @@ bucket_definitions:
1004
1098
  await bucketStateCollection.insertOne({
1005
1099
  _id: { d: definitionId, b: BUCKET },
1006
1100
  last_op: lastOp,
1007
- estimate_since_compact: { count: 10, bytes: 100 }
1101
+ next_compact_check: new Date(0),
1102
+ first_uncompacted_write: new Date(0),
1103
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1008
1104
  });
1009
1105
  }
1010
1106
 
@@ -1030,6 +1126,156 @@ bucket_definitions:
1030
1126
  return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1031
1127
  }
1032
1128
 
1129
+ test('scheduled compaction claims only due buckets and clears completed full work', async () => {
1130
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1131
+ const docs = [
1132
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
1133
+ serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
1134
+ ];
1135
+ await insertDocs(collection, docs);
1136
+ await bucketStateCollection.insertOne({
1137
+ _id: { d: ctx.definitionId, b: BUCKET },
1138
+ last_op: 2n,
1139
+ next_compact_check: new Date(Date.now() + 60_000),
1140
+ first_uncompacted_write: new Date(Date.now() - 60_000),
1141
+ bucket_stats: {
1142
+ count: 2,
1143
+ bytes: BigInt(docs[0].size + docs[1].size),
1144
+ chunks: 2
1145
+ }
1146
+ });
1147
+
1148
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1149
+ expect(
1150
+ (await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
1151
+ ).toBeUndefined();
1152
+
1153
+ await bucketStateCollection.updateOne(
1154
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1155
+ { $set: { next_compact_check: new Date(Date.now() - 1) } }
1156
+ );
1157
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1158
+
1159
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1160
+ expect(state?.last_full_compact?.op_id).toBe(2n);
1161
+ expect(state?.first_uncompacted_write).toBeUndefined();
1162
+ expect(state?.next_compact_check).toBeUndefined();
1163
+ expect(state?.compact_lease).toBeUndefined();
1164
+ });
1165
+
1166
+ test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
1167
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1168
+ const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
1169
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
1170
+ await insertDocs(collection, [prefix, untouchedTail]);
1171
+ await bucketStateCollection.insertOne({
1172
+ _id: { d: ctx.definitionId, b: BUCKET },
1173
+ last_op: 2n,
1174
+ next_compact_check: new Date(0),
1175
+ first_uncompacted_write: new Date(0),
1176
+ bucket_stats: {
1177
+ count: prefix.count + untouchedTail.count,
1178
+ bytes: BigInt(prefix.size + untouchedTail.size),
1179
+ chunks: 2
1180
+ }
1181
+ });
1182
+
1183
+ await bucketStorage.compact({ maxOpId: 1n });
1184
+
1185
+ const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1186
+ expect(partialState?.last_full_compact).toMatchObject({
1187
+ op_id: 1n,
1188
+ count: prefix.count,
1189
+ puts: 1
1190
+ });
1191
+ expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
1192
+ expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
1193
+ expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
1194
+ partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
1195
+ );
1196
+
1197
+ const lastFullCompactAt = partialState!.last_full_compact!.at;
1198
+ const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
1199
+ await bucketStateCollection.updateOne(
1200
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1201
+ { $set: { next_compact_check: new Date(0) } }
1202
+ );
1203
+
1204
+ await bucketStorage.compact({ maxOpId: 2n });
1205
+
1206
+ const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1207
+ expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1208
+ expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
1209
+ expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
1210
+
1211
+ // Even after the scheduling window has elapsed, a run with the old cap
1212
+ // must not scan and record the same full-compaction prefix again.
1213
+ await bucketStateCollection.updateOne(
1214
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1215
+ { $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
1216
+ );
1217
+
1218
+ await bucketStorage.compact({ maxOpId: 1n });
1219
+
1220
+ const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1221
+ expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1222
+ expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
1223
+ expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
1224
+ });
1225
+
1226
+ test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
1227
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1228
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1229
+ await insertDocs(collection, [document]);
1230
+ await bucketStateCollection.insertOne({
1231
+ _id: { d: ctx.definitionId, b: BUCKET },
1232
+ last_op: 1n,
1233
+ next_compact_check: undefined,
1234
+ first_uncompacted_write: undefined,
1235
+ compacted_state: {
1236
+ op_id: 1n,
1237
+ checksum: document.checksum,
1238
+ count: document.count,
1239
+ bytes: BigInt(document.size),
1240
+ chunks: 1,
1241
+ at: new Date()
1242
+ },
1243
+ last_full_compact: {
1244
+ op_id: 1n,
1245
+ count: document.count,
1246
+ puts: 1,
1247
+ at: new Date()
1248
+ },
1249
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
1250
+ });
1251
+
1252
+ await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
1253
+
1254
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1255
+ expect(state?.compact_lease).toBeUndefined();
1256
+ expect(state?.next_compact_check).toBeNull();
1257
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1258
+ });
1259
+
1260
+ test('concurrent scheduled compactors lease a bucket to one worker', async () => {
1261
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1262
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1263
+ await insertDocs(collection, [document]);
1264
+ await bucketStateCollection.insertOne({
1265
+ _id: { d: ctx.definitionId, b: BUCKET },
1266
+ last_op: 1n,
1267
+ next_compact_check: new Date(Date.now() - 1),
1268
+ first_uncompacted_write: new Date(0),
1269
+ bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
1270
+ });
1271
+
1272
+ await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
1273
+
1274
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1275
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1276
+ expect(state?.compact_lease).toBeUndefined();
1277
+ });
1278
+
1033
1279
  test('1. superseded ops become MOVE tombstones', async () => {
1034
1280
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1035
1281
  // Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
@@ -1207,7 +1453,6 @@ bucket_definitions:
1207
1453
 
1208
1454
  describe('V3 MOVE tombstone properties', () => {
1209
1455
  const BUCKET = 'global[]';
1210
- const TABLE = 'items';
1211
1456
 
1212
1457
  async function setupV3() {
1213
1458
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -1238,31 +1483,6 @@ bucket_definitions:
1238
1483
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1239
1484
  }
1240
1485
 
1241
- function makeOp(
1242
- opId: number,
1243
- rowId: string,
1244
- data: string,
1245
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1246
- sourceTableId: bson.ObjectId,
1247
- overrides?: { op?: 'PUT' | 'REMOVE' }
1248
- ): BucketDataDoc {
1249
- return {
1250
- bucketKey: {
1251
- replicationStreamId: ctx.replicationStreamId,
1252
- definitionId: ctx.definitionId,
1253
- bucket: ctx.bucket
1254
- },
1255
- o: BigInt(opId),
1256
- op: overrides?.op ?? 'PUT',
1257
- source_table: sourceTableId,
1258
- source_key: test_utils.rid(rowId),
1259
- table: TABLE,
1260
- row_id: rowId,
1261
- checksum: BigInt(opId * 7),
1262
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1263
- };
1264
- }
1265
-
1266
1486
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1267
1487
  await collection.insertMany(docs);
1268
1488
  }
@@ -1271,7 +1491,9 @@ bucket_definitions:
1271
1491
  await bucketStateCollection.insertOne({
1272
1492
  _id: { d: definitionId, b: BUCKET },
1273
1493
  last_op: lastOp,
1274
- estimate_since_compact: { count: 10, bytes: 100 }
1494
+ next_compact_check: new Date(0),
1495
+ first_uncompacted_write: new Date(0),
1496
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1275
1497
  });
1276
1498
  }
1277
1499
 
@@ -1420,10 +1642,9 @@ bucket_definitions:
1420
1642
  */
1421
1643
  describe('Streaming compactor', () => {
1422
1644
  const BUCKET = 'global[]';
1423
- const TABLE = 'items';
1424
1645
 
1425
- async function setupV3() {
1426
- await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1646
+ async function setupV3(factoryGenerator = INITIALIZED_MONGO_STORAGE_FACTORY) {
1647
+ await using factory = await factoryGenerator.factory();
1427
1648
  const syncRules = await factory.updateSyncRules(
1428
1649
  updateSyncRulesFromYaml(
1429
1650
  `
@@ -1450,31 +1671,6 @@ bucket_definitions:
1450
1671
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1451
1672
  }
1452
1673
 
1453
- function makeOp(
1454
- opId: number,
1455
- rowId: string,
1456
- data: string,
1457
- ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1458
- sourceTableId: bson.ObjectId,
1459
- overrides?: { op?: 'PUT' | 'REMOVE' }
1460
- ): BucketDataDoc {
1461
- return {
1462
- bucketKey: {
1463
- replicationStreamId: ctx.replicationStreamId,
1464
- definitionId: ctx.definitionId,
1465
- bucket: ctx.bucket
1466
- },
1467
- o: BigInt(opId),
1468
- op: overrides?.op ?? 'PUT',
1469
- source_table: sourceTableId,
1470
- source_key: test_utils.rid(rowId),
1471
- table: TABLE,
1472
- row_id: rowId,
1473
- checksum: BigInt(opId * 7),
1474
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1475
- };
1476
- }
1477
-
1478
1674
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1479
1675
  await collection.insertMany(docs);
1480
1676
  }
@@ -1483,7 +1679,9 @@ bucket_definitions:
1483
1679
  await bucketStateCollection.insertOne({
1484
1680
  _id: { d: definitionId, b: BUCKET },
1485
1681
  last_op: lastOp,
1486
- estimate_since_compact: { count: 10, bytes: 100 }
1682
+ next_compact_check: new Date(0),
1683
+ first_uncompacted_write: new Date(0),
1684
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1487
1685
  });
1488
1686
  }
1489
1687
 
@@ -1501,6 +1699,523 @@ bucket_definitions:
1501
1699
  );
1502
1700
  }
1503
1701
 
1702
+ async function setupCompactedTail() {
1703
+ const setup = await setupV3();
1704
+ const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
1705
+ const documents = [
1706
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1707
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1708
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1709
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1710
+ ];
1711
+ await insertDocs(collection, documents);
1712
+ await bucketStateCollection.insertOne({
1713
+ _id: { d: ctx.definitionId, b: BUCKET },
1714
+ last_op: 4n,
1715
+ next_compact_check: new Date(0),
1716
+ first_uncompacted_write: new Date(0),
1717
+ compacted_state: {
1718
+ op_id: 2n,
1719
+ checksum: documents[0].checksum + documents[1].checksum,
1720
+ count: documents[0].count + documents[1].count,
1721
+ bytes: BigInt(documents[0].size + documents[1].size),
1722
+ chunks: 2,
1723
+ at: new Date(0)
1724
+ },
1725
+ bucket_stats: {
1726
+ count: documents.reduce((total, document) => total + document.count, 0),
1727
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1728
+ chunks: documents.length
1729
+ }
1730
+ });
1731
+ return setup;
1732
+ }
1733
+
1734
+ async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
1735
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1736
+ expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
1737
+ 1n,
1738
+ 2n,
1739
+ 3n,
1740
+ 4n
1741
+ ]);
1742
+
1743
+ const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
1744
+ const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
1745
+ expect(state?.compacted_state).toMatchObject({
1746
+ op_id: 4n,
1747
+ checksum: 70n,
1748
+ count: 4,
1749
+ bytes,
1750
+ chunks: documents.length
1751
+ });
1752
+ expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
1753
+ }
1754
+
1755
+ test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
1756
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1757
+ await insertDocs(collection, [
1758
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1759
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
1760
+ ]);
1761
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
1762
+
1763
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1764
+
1765
+ expect(result).toEqual({ buckets: 1 });
1766
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1767
+ expect(documents).toHaveLength(1);
1768
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
1769
+
1770
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1771
+ expect(state?.compacted_state).toMatchObject({
1772
+ op_id: 4n,
1773
+ count: 4,
1774
+ checksum: 70n
1775
+ });
1776
+ });
1777
+
1778
+ test.each([
1779
+ { payloadSize: 100, workers: undefined, abort: false },
1780
+ { payloadSize: 450_000, workers: undefined, abort: false },
1781
+ { payloadSize: 450_000, workers: 4, abort: false },
1782
+ { payloadSize: 100, workers: 1, abort: false },
1783
+ { payloadSize: 450_000, workers: 4, abort: true }
1784
+ ])('chunk compaction shares configured workers across runs: %o', async ({ payloadSize, workers, abort }) => {
1785
+ const concurrency = workers ?? 4;
1786
+ const objectStorage = new MemoryObjectStorage();
1787
+ const generator = mongoTestStorageFactoryGenerator({
1788
+ url: env.MONGO_TEST_URL,
1789
+ isCI: env.CI,
1790
+ objectStorage,
1791
+ chunkCompactionConcurrency: workers,
1792
+ inlineThresholdBytes: 0
1793
+ });
1794
+ const { bucketStorage, syncRules, collection, bucketStateCollection, ctx, sourceTableId, db } =
1795
+ await setupV3(generator);
1796
+ const bucketCount = 12;
1797
+ for (let i = 0; i < bucketCount; i++) {
1798
+ const bucket = `global[${i}]`;
1799
+ const documents = [1, 2].map((id) =>
1800
+ serializeBucketData(bucket, [makeOp(id, String(id), 'x'.repeat(payloadSize), ctx, sourceTableId)])
1801
+ );
1802
+ await collection.insertMany(documents);
1803
+ await bucketStateCollection.insertOne({
1804
+ _id: { d: ctx.definitionId, b: bucket },
1805
+ last_op: 2n,
1806
+ next_compact_check: new Date(0),
1807
+ first_uncompacted_write: new Date(0),
1808
+ bucket_stats: {
1809
+ count: 2,
1810
+ bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)),
1811
+ chunks: 2
1812
+ }
1813
+ });
1814
+ }
1815
+
1816
+ const gate = Promise.withResolvers<void>();
1817
+ const originalPut = objectStorage.put.bind(objectStorage);
1818
+ let uploads = 0;
1819
+ const put = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
1820
+ uploads++;
1821
+ await gate.promise;
1822
+ await originalPut(...args);
1823
+ });
1824
+ const controller = new AbortController();
1825
+ let settled = false;
1826
+ const runs = Promise.allSettled(
1827
+ Array.from({ length: 2 }, () =>
1828
+ bucketStorage.factory
1829
+ .getInstance(syncRules)
1830
+ .compactInitialReplication({ maxOpId: 2n, signal: controller.signal })
1831
+ )
1832
+ ).finally(() => {
1833
+ settled = true;
1834
+ });
1835
+ try {
1836
+ await vi.waitFor(() => expect(uploads).toBe(concurrency));
1837
+ // Let other ready workers reach admission; the held uploads must prevent
1838
+ // any additional payloads from reaching S3, across both compactor instances.
1839
+ await new Promise((resolve) => setTimeout(resolve, 50));
1840
+ expect(uploads).toBe(concurrency);
1841
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBeLessThanOrEqual(
1842
+ concurrency
1843
+ );
1844
+ expect(await collection.countDocuments()).toBe(bucketCount * 2);
1845
+ if (abort) {
1846
+ controller.abort();
1847
+ await new Promise((resolve) => setTimeout(resolve, 50));
1848
+ expect(settled).toBe(false);
1849
+ }
1850
+ } finally {
1851
+ gate.resolve();
1852
+ await runs;
1853
+ put.mockRestore();
1854
+ }
1855
+
1856
+ const results = await runs;
1857
+ if (abort) {
1858
+ expect(results.every((result) => result.status === 'rejected')).toBe(true);
1859
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
1860
+ // Cancellation must return all worker slots.
1861
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: 2n })).toEqual({ buckets: bucketCount });
1862
+ } else {
1863
+ expect(results.every((result) => result.status === 'fulfilled')).toBe(true);
1864
+ expect(results.reduce((sum, result) => sum + (result.status === 'fulfilled' ? result.value.buckets : 0), 0)).toBe(
1865
+ bucketCount
1866
+ );
1867
+ }
1868
+
1869
+ const documents = await collection.find().toArray();
1870
+ expect(documents).toHaveLength(bucketCount);
1871
+ expect(documents.every((doc) => doc.count === 2 && doc.checksum === 21n && doc.storage_ref != null)).toBe(true);
1872
+ expect(
1873
+ await bucketStateCollection.countDocuments({ 'compacted_state.op_id': 2n, compact_lease: { $exists: false } })
1874
+ ).toBe(bucketCount);
1875
+ const usage = await db.objectStorageUsage.find({ '_id.g': ctx.replicationStreamId }).toArray();
1876
+ const activeBytes = usage.reduce((sum, doc) => sum + BigInt(doc.definitions[ctx.definitionId] ?? 0), 0n);
1877
+ expect(activeBytes).toBe(documents.reduce((sum, doc) => sum + BigInt(doc.storage_ref!.file_size), 0n));
1878
+ });
1879
+
1880
+ test.each([2, 3])('failed chunk worker stops queued work and drains siblings (%s workers)', async (workerCount) => {
1881
+ const { bucketStorage } = await setupV3();
1882
+ const compactor = bucketStorage.createMongoCompactor({}) as any;
1883
+ const failure = new Error('worker failed');
1884
+ const failing = Promise.withResolvers<void>();
1885
+ const draining = Promise.withResolvers<void>();
1886
+ const started: number[] = [];
1887
+ let settled = false;
1888
+ // The factory has two slots. With three workers, one also waits for a slot
1889
+ // when its sibling fails; it must release that slot without starting work.
1890
+ const run = compactor
1891
+ .runChunkCompactionWorkers(
1892
+ [0, 1, 2, 3, 4],
1893
+ Array.from({ length: workerCount }, () => compactor.objectStorageUsage),
1894
+ async (bucket: number) => {
1895
+ started.push(bucket);
1896
+ if (bucket === 0) await failing.promise;
1897
+ if (bucket === 1) await draining.promise;
1898
+ }
1899
+ )
1900
+ .then(
1901
+ () => undefined,
1902
+ (error: unknown) => error
1903
+ )
1904
+ .finally(() => {
1905
+ settled = true;
1906
+ });
1907
+ try {
1908
+ await vi.waitFor(() => expect(started).toEqual([0, 1]));
1909
+ failing.reject(failure);
1910
+ await new Promise<void>((resolve) => setImmediate(resolve));
1911
+ expect(started).toEqual([0, 1]);
1912
+ expect(settled).toBe(false);
1913
+ } finally {
1914
+ failing.resolve();
1915
+ draining.resolve();
1916
+ await run;
1917
+ }
1918
+ expect(await run).toBe(failure);
1919
+ expect(started).toEqual([0, 1]);
1920
+ expect(bucketStorage.factory.chunkCompactionSlots.getValue()).toBe(2);
1921
+ });
1922
+
1923
+ test('unforced chunk workers drain before full compaction and reclassify aged buckets on the next scan', async () => {
1924
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1925
+ const buckets = ['chunk1[]', 'chunk2[]', 'promoted[]', 'full1[]', 'full2[]'];
1926
+ for (const bucket of buckets) {
1927
+ const documents = Array.from({ length: 8 }, (_, index) =>
1928
+ serializeBucketData(bucket, [makeOp(index + 1, String(index), 'data', { ...ctx, bucket }, sourceTableId)])
1929
+ );
1930
+ await collection.insertMany(documents);
1931
+ await bucketStateCollection.insertOne({
1932
+ _id: { d: ctx.definitionId, b: bucket },
1933
+ last_op: 8n,
1934
+ next_compact_check: new Date(0),
1935
+ first_uncompacted_write: bucket.startsWith('full') ? new Date(0) : new Date(),
1936
+ bucket_stats: { count: 8, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
1937
+ });
1938
+ }
1939
+
1940
+ const originalClaim = CompactionLease.claim.bind(CompactionLease);
1941
+ const jobStartedAt = new Date();
1942
+ let serverTime = jobStartedAt;
1943
+ let promoted = false;
1944
+ const claim = vi.spyOn(CompactionLease, 'claim').mockImplementation(async (...args) => {
1945
+ if (!promoted && (args[1]._id as { b: string })?.b === 'promoted[]') {
1946
+ promoted = true;
1947
+ // Advance the server clock past the full-compaction interval without
1948
+ // changing bucket state. A fixed scan timestamp would retry forever.
1949
+ serverTime = new Date(jobStartedAt.getTime() + 3 * 60 * 60 * 1000);
1950
+ }
1951
+ const lease = await originalClaim(...args);
1952
+ if (lease != null) {
1953
+ Object.defineProperty(lease, 'startedAt', { value: serverTime });
1954
+ }
1955
+ return lease;
1956
+ });
1957
+
1958
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 8n });
1959
+ const internal = compactor as any;
1960
+ let timeReads = 0;
1961
+ const clock = vi.spyOn(internal, 'readCompactionTime').mockImplementation(async () => {
1962
+ if (++timeReads > 5) {
1963
+ throw new Error('Compaction did not advance after the bucket aged');
1964
+ }
1965
+ return serverTime;
1966
+ });
1967
+ const scan = vi.spyOn(internal, 'findScheduledBucketBatch');
1968
+ const originalChunks = internal.compactSingleBucketChunks.bind(compactor);
1969
+ const originalFull = internal.compactSingleBucketFully.bind(compactor);
1970
+ const gate = Promise.withResolvers<void>();
1971
+ let activeChunks = 0;
1972
+ let activeFull = 0;
1973
+ let maxActiveFull = 0;
1974
+ const fullBuckets: string[] = [];
1975
+ const chunks = vi.spyOn(internal, 'compactSingleBucketChunks').mockImplementation(async (...args) => {
1976
+ activeChunks++;
1977
+ try {
1978
+ await gate.promise;
1979
+ return await originalChunks(...args);
1980
+ } finally {
1981
+ activeChunks--;
1982
+ }
1983
+ });
1984
+ const full = vi.spyOn(internal, 'compactSingleBucketFully').mockImplementation(async (...args) => {
1985
+ expect(activeChunks).toBe(0);
1986
+ activeFull++;
1987
+ maxActiveFull = Math.max(maxActiveFull, activeFull);
1988
+ fullBuckets.push((args[0] as any).state._id.b);
1989
+ try {
1990
+ return await originalFull(...args);
1991
+ } finally {
1992
+ activeFull--;
1993
+ }
1994
+ });
1995
+ const run = compactor.compact();
1996
+ try {
1997
+ await vi.waitFor(() => expect(activeChunks).toBe(2));
1998
+ expect(full).not.toHaveBeenCalled();
1999
+ } finally {
2000
+ gate.resolve();
2001
+ try {
2002
+ await run;
2003
+ expect(timeReads).toBe(3); // Job start, first batch, and promoted bucket's batch.
2004
+ expect(scan).toHaveBeenCalledTimes(3); // Includes the final empty scan.
2005
+ for (const [cutoff] of scan.mock.calls) {
2006
+ expect(cutoff).toEqual(jobStartedAt);
2007
+ }
2008
+ } finally {
2009
+ clock.mockRestore();
2010
+ scan.mockRestore();
2011
+ claim.mockRestore();
2012
+ chunks.mockRestore();
2013
+ full.mockRestore();
2014
+ }
2015
+ }
2016
+ expect(await run).toBe(5);
2017
+ expect(promoted).toBe(true);
2018
+ expect(maxActiveFull).toBe(1);
2019
+ expect(fullBuckets.sort()).toEqual(['full1[]', 'full2[]', 'promoted[]']);
2020
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
2021
+ expect(await collection.countDocuments()).toBe(5);
2022
+ });
2023
+
2024
+ test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
2025
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2026
+ const operations = [
2027
+ makeOp(1, 'A', 'a', ctx, sourceTableId),
2028
+ makeOp(2, 'B', 'b', ctx, sourceTableId),
2029
+ makeOp(3, 'C', 'c', ctx, sourceTableId),
2030
+ makeOp(4, 'D', 'd', ctx, sourceTableId)
2031
+ ];
2032
+ const originalDocuments = [
2033
+ serializeBucketData(BUCKET, operations.slice(0, 2)),
2034
+ serializeBucketData(BUCKET, operations.slice(2))
2035
+ ];
2036
+ const committedMerge = serializeBucketData(BUCKET, operations);
2037
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
2038
+ await insertDocs(collection, [committedMerge, untouchedTail]);
2039
+ await bucketStateCollection.insertOne({
2040
+ _id: { d: ctx.definitionId, b: BUCKET },
2041
+ last_op: 5n,
2042
+ next_compact_check: new Date(0),
2043
+ first_uncompacted_write: new Date(0),
2044
+ bucket_stats: {
2045
+ count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
2046
+ bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
2047
+ chunks: originalDocuments.length + 1
2048
+ }
2049
+ });
2050
+
2051
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
2052
+
2053
+ expect(result).toEqual({ buckets: 1 });
2054
+ const expectedStats = {
2055
+ count: committedMerge.count + untouchedTail.count,
2056
+ bytes: BigInt(committedMerge.size + untouchedTail.size),
2057
+ chunks: 2
2058
+ };
2059
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2060
+ expect(state?.bucket_stats).toEqual(expectedStats);
2061
+ expect(state?.compacted_state).toMatchObject({
2062
+ op_id: 4n,
2063
+ checksum: committedMerge.checksum,
2064
+ count: committedMerge.count,
2065
+ bytes: BigInt(committedMerge.size),
2066
+ chunks: 1
2067
+ });
2068
+ });
2069
+
2070
+ test('later full compaction claims fresh state after a committed replacement', async () => {
2071
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2072
+ const documents = [
2073
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
2074
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
2075
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
2076
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
2077
+ ];
2078
+ await insertDocs(collection, documents);
2079
+ await bucketStateCollection.insertOne({
2080
+ _id: { d: ctx.definitionId, b: BUCKET },
2081
+ last_op: 4n,
2082
+ next_compact_check: new Date(0),
2083
+ first_uncompacted_write: new Date(0),
2084
+ bucket_stats: {
2085
+ count: documents.reduce((total, document) => total + document.count, 0),
2086
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
2087
+ chunks: documents.length
2088
+ }
2089
+ });
2090
+
2091
+ const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
2092
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
2093
+ const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
2094
+ let injectedFailure = false;
2095
+ vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
2096
+ const result = await originalFlush(...args);
2097
+ if (!injectedFailure) {
2098
+ injectedFailure = true;
2099
+ await insertDocs(collection, [concurrentDocument]);
2100
+ await bucketStateCollection.updateOne(
2101
+ { _id: { d: ctx.definitionId, b: BUCKET } },
2102
+ {
2103
+ $set: { last_op: 5n },
2104
+ $inc: {
2105
+ 'bucket_stats.count': concurrentDocument.count,
2106
+ 'bucket_stats.bytes': BigInt(concurrentDocument.size),
2107
+ 'bucket_stats.chunks': 1
2108
+ }
2109
+ }
2110
+ );
2111
+ throw new ObjectStorageError('failure after committed full-compaction replacement', {
2112
+ cause: new Error('socket reset'),
2113
+ retryable: true
2114
+ });
2115
+ }
2116
+ return result;
2117
+ });
2118
+
2119
+ await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
2120
+
2121
+ expect(injectedFailure).toBe(true);
2122
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2123
+ expect(interruptedState?.last_op).toBe(5n);
2124
+ expect(interruptedState?.compacted_state).toBeUndefined();
2125
+ expect(interruptedState?.compact_lease).toBeUndefined();
2126
+
2127
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
2128
+ 1
2129
+ );
2130
+
2131
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
2132
+ expect(currentDocuments).toHaveLength(1);
2133
+ const expectedStats = {
2134
+ count: currentDocuments.reduce((total, document) => total + document.count, 0),
2135
+ bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
2136
+ chunks: currentDocuments.length
2137
+ };
2138
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2139
+ expect(state?.bucket_stats).toEqual(expectedStats);
2140
+ expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
2141
+ expect(state?.last_full_compact?.op_id).toBe(5n);
2142
+ expect(state?.first_uncompacted_write).toBeUndefined();
2143
+ expect(state?.next_compact_check).toBeUndefined();
2144
+ });
2145
+
2146
+ test('chunk compaction treats a missing cached op as a resume hint', async () => {
2147
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2148
+ const documents = [
2149
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
2150
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
2151
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
2152
+ ];
2153
+ await insertDocs(collection, documents);
2154
+ await bucketStateCollection.insertOne({
2155
+ _id: { d: ctx.definitionId, b: BUCKET },
2156
+ last_op: 4n,
2157
+ next_compact_check: new Date(0),
2158
+ first_uncompacted_write: new Date(0),
2159
+ compacted_state: {
2160
+ op_id: 2n,
2161
+ checksum: 21n,
2162
+ count: 2,
2163
+ bytes: 100n,
2164
+ chunks: 2,
2165
+ at: new Date(0)
2166
+ },
2167
+ bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
2168
+ });
2169
+
2170
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
2171
+ 1
2172
+ );
2173
+
2174
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2175
+ expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
2176
+ const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
2177
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2178
+ expect(state?.compacted_state).toMatchObject({
2179
+ op_id: 4n,
2180
+ checksum: 56n,
2181
+ count: 3,
2182
+ bytes,
2183
+ chunks: currentDocuments.length
2184
+ });
2185
+ expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
2186
+ });
2187
+
2188
+ test('later chunk compaction rebuilds state after a committed partial merge', async () => {
2189
+ const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
2190
+ const failedCompactor = bucketStorage.createMongoCompactor({
2191
+ maxOpId: 4n,
2192
+ compactChunksOnly: true
2193
+ });
2194
+ const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
2195
+ const flushSpy = vi
2196
+ .spyOn(failedCompactor as any, 'flushCompactionGroup')
2197
+ .mockImplementation(async (...args: any[]) => {
2198
+ const result = await originalFlush(...args);
2199
+ throw new Error('failure after committed chunk merge');
2200
+ });
2201
+
2202
+ await expect(failedCompactor.compact()).resolves.toBe(0);
2203
+ flushSpy.mockRestore();
2204
+
2205
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2206
+ expect(interruptedState?.compacted_state?.op_id).toBe(2n);
2207
+ expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
2208
+ await bucketStateCollection.updateOne(
2209
+ { _id: { d: ctx.definitionId, b: BUCKET } },
2210
+ { $set: { next_compact_check: new Date(0) } }
2211
+ );
2212
+
2213
+ const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
2214
+
2215
+ expect(result).toBe(1);
2216
+ await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
2217
+ });
2218
+
1504
2219
  test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
1505
2220
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1506
2221
 
@@ -1616,6 +2331,13 @@ bucket_definitions:
1616
2331
  expect(op600).toBeDefined();
1617
2332
  expect(op600!.op).toBe('PUT');
1618
2333
  expect(op600!.row_id).toBe('F');
2334
+
2335
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2336
+ expect(state?.bucket_stats).toEqual({
2337
+ count: docsAfter.reduce((total, document) => total + document.count, 0),
2338
+ bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
2339
+ chunks: docsAfter.length
2340
+ });
1619
2341
  });
1620
2342
 
1621
2343
  test('3. seen map overflow - some old ops pass through without tombstoning', async () => {