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

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 (213) hide show
  1. package/CHANGELOG.md +129 -7
  2. package/dist/storage/MongoBucketStorage.d.ts +42 -5
  3. package/dist/storage/MongoBucketStorage.js +404 -215
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +39 -14
  6. package/dist/storage/implementation/MongoBucketBatch.js +169 -98
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +21 -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/MongoIdSequence.d.ts +10 -1
  12. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  13. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  14. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  16. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  18. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +35 -2
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +2 -1
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +7 -4
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -1
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +3 -3
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +10 -8
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  29. package/dist/storage/implementation/MongoStorageProvider.js +8 -4
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +32 -11
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +95 -31
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +59 -10
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  38. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  39. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  44. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  45. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  46. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  47. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  48. package/dist/storage/implementation/common/PersistedBatch.js +57 -14
  49. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  50. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  51. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  53. package/dist/storage/implementation/db.d.ts +9 -1
  54. package/dist/storage/implementation/db.js +28 -1
  55. package/dist/storage/implementation/db.js.map +1 -1
  56. package/dist/storage/implementation/models.d.ts +35 -0
  57. package/dist/storage/implementation/models.js.map +1 -1
  58. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +11 -0
  59. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +227 -221
  60. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  61. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  62. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  63. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  64. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  65. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  66. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  67. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  68. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +66 -17
  69. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  70. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  71. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  72. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  73. package/dist/storage/implementation/v1/models.js +1 -0
  74. package/dist/storage/implementation/v1/models.js.map +1 -1
  75. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  76. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  77. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  78. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +14 -2
  79. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +261 -194
  80. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  81. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +83 -25
  82. package/dist/storage/implementation/v3/MongoCompactorV3.js +702 -159
  83. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  85. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  86. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  87. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  88. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  89. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  90. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  91. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +92 -32
  92. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  93. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  94. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  95. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  96. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  97. package/dist/storage/implementation/v3/PersistedBatchV3.js +77 -45
  98. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  99. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  100. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  101. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  102. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  103. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  104. package/dist/storage/implementation/v3/compact-utils.d.ts +108 -0
  105. package/dist/storage/implementation/v3/compact-utils.js +223 -0
  106. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  107. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  108. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  109. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  110. package/dist/storage/implementation/v3/models.d.ts +90 -6
  111. package/dist/storage/implementation/v3/models.js.map +1 -1
  112. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  113. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  114. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  115. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  116. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  117. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  118. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  119. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  120. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  121. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  122. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  123. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  124. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  125. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  126. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  127. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  128. package/dist/storage/storage-index.d.ts +2 -1
  129. package/dist/storage/storage-index.js +1 -0
  130. package/dist/storage/storage-index.js.map +1 -1
  131. package/dist/types/types.d.ts +28 -1
  132. package/dist/types/types.js +28 -7
  133. package/dist/types/types.js.map +1 -1
  134. package/dist/utils/test-utils.d.ts +4 -2
  135. package/dist/utils/test-utils.js +7 -4
  136. package/dist/utils/test-utils.js.map +1 -1
  137. package/dist/utils/util.js +3 -1
  138. package/dist/utils/util.js.map +1 -1
  139. package/package.json +10 -9
  140. package/src/storage/MongoBucketStorage.ts +545 -277
  141. package/src/storage/implementation/MongoBucketBatch.ts +258 -124
  142. package/src/storage/implementation/MongoCompactor.ts +37 -346
  143. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  144. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  145. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  146. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +46 -1
  147. package/src/storage/implementation/MongoPersistedReplicationStream.ts +12 -4
  148. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +48 -29
  149. package/src/storage/implementation/MongoStorageProvider.ts +16 -4
  150. package/src/storage/implementation/MongoSyncBucketStorage.ts +133 -40
  151. package/src/storage/implementation/MongoSyncRulesLock.ts +78 -12
  152. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  153. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  154. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  155. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  156. package/src/storage/implementation/common/PersistedBatch.ts +76 -20
  157. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  158. package/src/storage/implementation/db.ts +36 -2
  159. package/src/storage/implementation/models.ts +39 -0
  160. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +272 -254
  161. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  162. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  163. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +88 -28
  164. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  165. package/src/storage/implementation/v1/models.ts +1 -0
  166. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  167. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +339 -247
  168. package/src/storage/implementation/v3/MongoCompactorV3.ts +955 -225
  169. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  170. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  171. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +112 -42
  172. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  173. package/src/storage/implementation/v3/PersistedBatchV3.ts +94 -51
  174. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  175. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  176. package/src/storage/implementation/v3/compact-utils.ts +329 -0
  177. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  178. package/src/storage/implementation/v3/models.ts +97 -4
  179. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  180. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  181. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  182. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  183. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  184. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  185. package/src/storage/storage-index.ts +2 -0
  186. package/src/types/types.ts +42 -7
  187. package/src/utils/test-utils.ts +9 -5
  188. package/src/utils/util.ts +3 -1
  189. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  190. package/test/src/__snapshots__/storage_sync.test.ts.snap +794 -205
  191. package/test/src/chunk_compaction_config.test.ts +38 -0
  192. package/test/src/cleanup-stopped-sync-configs.test.ts +59 -26
  193. package/test/src/compact-utils.test.ts +251 -0
  194. package/test/src/concurrent_writers.test.ts +599 -0
  195. package/test/src/connection_config.test.ts +159 -0
  196. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  197. package/test/src/helpers/concurrentWriter.mjs +29 -0
  198. package/test/src/helpers/s3TestFactory.ts +12 -12
  199. package/test/src/object_storage_usage.test.ts +268 -0
  200. package/test/src/parameter_compacting_v1.test.ts +120 -0
  201. package/test/src/parameter_compacting_v3.test.ts +192 -0
  202. package/test/src/parameter_compaction_fence.test.ts +276 -0
  203. package/test/src/storage.test.ts +280 -24
  204. package/test/src/storage_compacting.test.ts +1097 -253
  205. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  206. package/test/src/storage_s3_checksums.test.ts +6 -7
  207. package/test/src/storage_s3_compaction_lifecycle.test.ts +143 -17
  208. package/test/src/storage_s3_reading.test.ts +294 -6
  209. package/test/src/storage_s3_writing.test.ts +4 -3
  210. package/test/src/storage_sync.test.ts +412 -80
  211. package/test/src/storeCurrentData.test.ts +5 -5
  212. package/test/src/util.ts +1 -1
  213. 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, getTestStorage, 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,37 +93,37 @@ 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
- const bucketStorage = factory.getInstance(syncRules);
109
+ const bucketStorage = await getTestStorage(factory, syncRules);
72
110
  const syncRulesContent = syncRules.syncConfigContent[0];
73
111
  const { checkpoint } = await populate(bucketStorage, 1);
74
112
 
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,40 +148,48 @@ 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
- const bucketStorage = factory.getInstance(syncRules);
167
+ const bucketStorage = await getTestStorage(factory, syncRules);
130
168
  const syncRulesContent = syncRules.syncConfigContent[0];
131
169
 
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
+ // Force V3's initial lite pass to process these small buckets for cache verification.
174
+ // Earlier storage versions use the default minimum-change threshold.
175
+ const result0 = await bucketStorage.compactInitialReplication({
176
+ forceChunkCompaction: true,
137
177
  maxOpId: checkpoint
138
178
  });
139
- expect(result0.buckets).toEqual(0);
179
+ expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
140
180
 
141
- // This should cache the checksums for the two buckets
142
- const result1 = await bucketStorage.populatePersistentChecksumCache({
181
+ // For V1/V2, lower the threshold to populate the checksum cache. V3 has
182
+ // already updated its compacted state, so another initial pass is a no-op.
183
+ const result1 = await bucketStorage.compactInitialReplication({
184
+ forceChunkCompaction: true,
143
185
  maxOpId: checkpoint,
144
186
  minBucketChanges: 1
145
187
  });
146
- expect(result1.buckets).toEqual(2);
188
+ expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
147
189
 
148
- // This should be a no-op, as the checksums are already cached
149
- const result2 = await bucketStorage.populatePersistentChecksumCache({
190
+ // Repeating it stays a no-op.
191
+ const result2 = await bucketStorage.compactInitialReplication({
192
+ forceChunkCompaction: true,
150
193
  maxOpId: checkpoint,
151
194
  minBucketChanges: 1
152
195
  });
@@ -168,88 +211,87 @@ bucket_definitions:
168
211
  });
169
212
  });
170
213
 
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;
214
+ test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
215
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
216
+ const result = await (bucketStorage as MongoSyncBucketStorage)
217
+ .createMongoCompactor({
218
+ maxOpId: checkpoint,
219
+ compactChunksOnly: true,
220
+ forceChunkCompaction: true,
221
+ minCompactChunkIntervalMs: 1
222
+ })
223
+ .compact();
224
+
225
+ expect(result).toBe(2);
226
+ });
182
227
 
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
- }
228
+ test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
229
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
230
+ const firstResult = await bucketStorage.compactInitialReplication({
231
+ forceChunkCompaction: true,
232
+ maxOpId: checkpoint
233
+ });
234
+ expect(firstResult.buckets).toBe(2);
226
235
 
227
- // This test uses a couple of "internal" APIs of the compactor.
228
- const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n });
236
+ const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
237
+ bucketStorage.replicationStreamId
238
+ );
239
+ await bucketStateCollection.updateMany(
240
+ {},
241
+ {
242
+ $set: {
243
+ first_uncompacted_write: new Date(0),
244
+ next_compact_check: new Date(0)
245
+ }
246
+ }
247
+ );
229
248
 
230
- const dirtyBuckets = compactor.dirtyBucketBatches({
231
- minBucketChanges: 1,
232
- minChangeRatio: 0.39
249
+ const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
250
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
251
+ .toArray();
252
+ const result = await bucketStorage.compactInitialReplication({
253
+ forceChunkCompaction: true,
254
+ maxOpId: checkpoint,
255
+ signal: AbortSignal.timeout(2_000)
233
256
  });
234
- const firstBatch = await dirtyBuckets.next();
235
257
 
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);
258
+ expect(result.buckets).toBe(0);
259
+ const states = await bucketStateCollection.find({}).toArray();
260
+ expect(states).toHaveLength(2);
261
+ for (const state of states) {
262
+ expect(state.next_compact_check!.getTime()).toBeGreaterThan(
263
+ now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
264
+ );
265
+ }
266
+ });
267
+
268
+ test('v3 replication writes initialize scheduled compaction state', async () => {
269
+ const { bucketStorage } = await setup();
270
+ const storageDb = bucketStorage.db;
242
271
 
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
- ]);
272
+ if (!storageDb.storageConfig.incrementalReprocessing) {
273
+ return;
274
+ }
275
+ const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
276
+ bucketStorage.replicationStreamId
277
+ );
278
+ const states = await bucketStateCollection.find({}).toArray();
279
+ expect(states).toHaveLength(2);
280
+ for (const state of states) {
281
+ expect(state.last_op).toBeGreaterThan(0n);
282
+ expect(state.bucket_stats.count).toBe(1);
283
+ expect(state.bucket_stats.chunks).toBe(1);
284
+ expect(state.first_uncompacted_write).toBeInstanceOf(Date);
285
+ expect(state.next_compact_check).toBeInstanceOf(Date);
286
+ expect(state.compacted_state).toBeUndefined();
287
+ expect(state).not.toHaveProperty('estimate_since_compact');
288
+
289
+ const docs = await (storageDb as VersionedPowerSyncMongoV3)
290
+ .bucketData(bucketStorage.replicationStreamId, state._id.d)
291
+ .find({ '_id.b': state._id.b })
292
+ .toArray();
293
+ expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
294
+ }
253
295
  });
254
296
  });
255
297
  });
@@ -284,14 +326,16 @@ describe('Mongo Sync Parameter Storage Compact', () => {
284
326
  */
285
327
  describe('V3 invariant verification', () => {
286
328
  const BUCKET = 'global[]';
287
- const TABLE = 'items';
288
329
 
289
330
  function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
331
+ const sourceTable = new bson.ObjectId();
332
+ const sourceKey = 'key';
290
333
  return {
291
334
  bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
292
335
  op: 'PUT',
293
- source_table: new bson.ObjectId(),
294
- source_key: 'key',
336
+ source_table: sourceTable,
337
+ source_key: sourceKey,
338
+ subkey: replicaIdToSubkey(sourceTable, sourceKey),
295
339
  table: 'test',
296
340
  row_id: 'row1',
297
341
  checksum: 1n,
@@ -312,7 +356,7 @@ bucket_definitions:
312
356
  { storageVersion: 3 }
313
357
  )
314
358
  );
315
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
359
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
316
360
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
317
361
  const mapping = syncRules.syncConfigContent[0].mapping;
318
362
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -329,31 +373,6 @@ bucket_definitions:
329
373
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
330
374
  }
331
375
 
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
376
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
358
377
  await collection.insertMany(docs);
359
378
  }
@@ -362,7 +381,9 @@ bucket_definitions:
362
381
  await bucketStateCollection.insertOne({
363
382
  _id: { d: definitionId, b: BUCKET },
364
383
  last_op: lastOp,
365
- estimate_since_compact: { count: 10, bytes: 100 }
384
+ next_compact_check: new Date(0),
385
+ first_uncompacted_write: new Date(0),
386
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
366
387
  });
367
388
  }
368
389
 
@@ -371,12 +392,222 @@ bucket_definitions:
371
392
  clearBatchLimit: 200,
372
393
  moveBatchLimit: 10,
373
394
  moveBatchQueryLimit: 10,
374
- minBucketChanges: 1,
375
- minChangeRatio: 0,
376
395
  maxOpId
377
396
  });
378
397
  }
379
398
 
399
+ test.each([1, 7])(
400
+ 'initial compaction bulk-reschedules buckets with %s chunks without reading data',
401
+ async (chunks) => {
402
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
403
+ const documents = Array.from({ length: chunks }, (_, index) =>
404
+ serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
405
+ );
406
+ await insertDocs(collection, documents);
407
+ await bucketStateCollection.insertOne({
408
+ _id: { d: ctx.definitionId, b: BUCKET },
409
+ last_op: BigInt(chunks),
410
+ next_compact_check: new Date(0),
411
+ // Even overdue full compaction must not force a small initial chunk merge.
412
+ first_uncompacted_write: new Date(0),
413
+ bucket_stats: { count: chunks, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks }
414
+ });
415
+ const claim = vi.spyOn(CompactionLease, 'claim');
416
+ const data = vi.spyOn(db, 'bucketData');
417
+ try {
418
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: BigInt(chunks) })).toEqual({ buckets: 0 });
419
+ expect(claim).not.toHaveBeenCalled();
420
+ expect(data).not.toHaveBeenCalled();
421
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
422
+ expect(state?.compacted_state).toBeUndefined();
423
+ expect(state?.compact_lease).toBeUndefined();
424
+ expect(state?.last_full_compact).toBeUndefined();
425
+ expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
426
+ expect(await collection.find().toArray()).toEqual(documents);
427
+ } finally {
428
+ claim.mockRestore();
429
+ data.mockRestore();
430
+ }
431
+ }
432
+ );
433
+
434
+ test('initial compaction rechecks the chunk threshold after claiming', async () => {
435
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId, db } = await setupV3Storage();
436
+ const documents = Array.from({ length: 8 }, (_, index) =>
437
+ serializeBucketData(BUCKET, [makeOp(index + 1, String(index), 'data', ctx, sourceTableId)])
438
+ );
439
+ await insertDocs(collection, documents);
440
+ const bytes = BigInt(documents.reduce((sum, doc) => sum + doc.size, 0));
441
+ await bucketStateCollection.insertOne({
442
+ _id: { d: ctx.definitionId, b: BUCKET },
443
+ last_op: 8n,
444
+ next_compact_check: new Date(0),
445
+ first_uncompacted_write: new Date(),
446
+ bucket_stats: { count: 8, bytes, chunks: 8 }
447
+ });
448
+ const originalClaim = CompactionLease.claim.bind(CompactionLease);
449
+ const claim = vi.spyOn(CompactionLease, 'claim').mockImplementationOnce(async (...args) => {
450
+ // Another compactor finishes before we acquire the lease, leaving one
451
+ // new chunk beyond its cached prefix instead of the eight we selected.
452
+ await bucketStateCollection.updateOne(
453
+ { _id: { d: ctx.definitionId, b: BUCKET } },
454
+ {
455
+ $set: {
456
+ compacted_state: {
457
+ op_id: 7n,
458
+ checksum: 196n,
459
+ count: 7,
460
+ bytes: bytes - BigInt(documents[7].size),
461
+ chunks: 7,
462
+ at: new Date()
463
+ }
464
+ }
465
+ }
466
+ );
467
+ return originalClaim(...args);
468
+ });
469
+ const data = vi.spyOn(db, 'bucketData');
470
+ try {
471
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 0 });
472
+ expect(claim).toHaveBeenCalledTimes(1);
473
+ expect(data).not.toHaveBeenCalled();
474
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
475
+ expect(state?.compacted_state?.op_id).toBe(7n);
476
+ expect(state?.compact_lease).toBeUndefined();
477
+ expect(state!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
478
+ } finally {
479
+ claim.mockRestore();
480
+ data.mockRestore();
481
+ }
482
+ });
483
+
484
+ test('a successful lease renewal clears a transient renewal error', async () => {
485
+ const { bucketStateCollection, ctx } = await setupV3Storage();
486
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
487
+ const lease = await CompactionLease.claim(
488
+ bucketStateCollection,
489
+ { _id: { d: ctx.definitionId, b: BUCKET } },
490
+ undefined,
491
+ 10 * 60 * 1000
492
+ );
493
+ expect(lease).not.toBeNull();
494
+
495
+ try {
496
+ const transientError = new Error('temporary MongoDB error');
497
+ const renew = (lease as any).renew.bind(lease);
498
+ const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
499
+ await renew().catch((error: unknown) => {
500
+ (lease as any).renewalError = error;
501
+ });
502
+ await expect(lease!.throwIfLost()).rejects.toBe(transientError);
503
+
504
+ updateOne.mockRestore();
505
+ await renew();
506
+ await expect(lease!.throwIfLost()).resolves.toBeUndefined();
507
+ } finally {
508
+ await lease?.[Symbol.asyncDispose]();
509
+ }
510
+ });
511
+
512
+ test('a failed scheduled bucket does not block other scheduled buckets', async () => {
513
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
514
+ const badBucket = 'bad[]';
515
+ const goodBucket = 'good[]';
516
+ const goodDocument = serializeBucketData(goodBucket, [
517
+ makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
518
+ ]);
519
+ await insertDocs(collection, [goodDocument]);
520
+ await bucketStateCollection.insertMany([
521
+ {
522
+ _id: { d: ctx.definitionId, b: badBucket },
523
+ last_op: 2n,
524
+ next_compact_check: new Date(0),
525
+ // A malformed scheduled bucket with an expired lease must be
526
+ // rescheduled without preventing valid buckets from compacting.
527
+ first_uncompacted_write: new Date(0),
528
+ bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
529
+ compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
530
+ },
531
+ {
532
+ _id: { d: ctx.definitionId, b: goodBucket },
533
+ last_op: 2n,
534
+ next_compact_check: new Date(0),
535
+ first_uncompacted_write: new Date(0),
536
+ bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
537
+ }
538
+ ]);
539
+ await bucketStateCollection.updateOne(
540
+ { _id: { d: ctx.definitionId, b: badBucket } },
541
+ { $unset: { first_uncompacted_write: '' } }
542
+ );
543
+
544
+ await (bucketStorage as MongoSyncBucketStorage)
545
+ .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true, forceChunkCompaction: true })
546
+ .compact();
547
+
548
+ const [badState, goodState] = await Promise.all([
549
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
550
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
551
+ ]);
552
+ expect(badState?.next_compact_check).toBeInstanceOf(Date);
553
+ expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
554
+ expect(goodState?.compacted_state?.op_id).toBe(2n);
555
+ });
556
+
557
+ test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
558
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
559
+ const buckets = Array.from({ length: 8 }, (_, index) => `bucket${index}[]`);
560
+ const documents = buckets.map((bucket) =>
561
+ serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
562
+ );
563
+ await insertDocs(collection, documents);
564
+ await bucketStateCollection.insertMany(
565
+ documents.map((document, index) => ({
566
+ _id: { d: ctx.definitionId, b: buckets[index] },
567
+ last_op: 2n,
568
+ next_compact_check: new Date(0),
569
+ first_uncompacted_write: new Date(0),
570
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
571
+ }))
572
+ );
573
+
574
+ const abortController = new AbortController();
575
+ const testLogger = defaultLogger.child({});
576
+ vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
577
+ if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
578
+ abortController.abort();
579
+ }
580
+ return testLogger;
581
+ });
582
+ const errorLog = vi.spyOn(testLogger, 'error');
583
+
584
+ await expect(
585
+ bucketStorage
586
+ .createMongoCompactor({
587
+ maxOpId: 2n,
588
+ compactChunksOnly: true,
589
+ forceChunkCompaction: true,
590
+ signal: abortController.signal,
591
+ logger: testLogger
592
+ })
593
+ .compact()
594
+ ).rejects.toThrow();
595
+
596
+ const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
597
+ const completed = states.filter((state) => state.compacted_state != null);
598
+ const remaining = states.filter((state) => state.compacted_state == null);
599
+ // Already-started buckets may finish after abort, but the queued remainder
600
+ // must not be claimed or rescheduled.
601
+ expect(completed.length).toBeGreaterThanOrEqual(1);
602
+ expect(completed.length).toBeLessThanOrEqual(2);
603
+ expect(remaining.length).toBeGreaterThanOrEqual(6);
604
+ for (const state of remaining) {
605
+ expect(state.next_compact_check).toEqual(new Date(0));
606
+ expect(state.compact_lease).toBeUndefined();
607
+ }
608
+ expect(errorLog).not.toHaveBeenCalled();
609
+ });
610
+
380
611
  test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
381
612
  const ops = [
382
613
  makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
@@ -746,7 +977,6 @@ bucket_definitions:
746
977
 
747
978
  describe('V3 checksum pipeline straddling', () => {
748
979
  const BUCKET = 'global[]';
749
- const TABLE = 'items';
750
980
 
751
981
  async function setup() {
752
982
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -760,7 +990,7 @@ bucket_definitions:
760
990
  { storageVersion: 3 }
761
991
  )
762
992
  );
763
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
993
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
764
994
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
765
995
  const mapping = syncRules.syncConfigContent[0].mapping;
766
996
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -776,30 +1006,6 @@ bucket_definitions:
776
1006
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
777
1007
  }
778
1008
 
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
1009
  function checksumRequest(): storage.BucketChecksumRequest {
804
1010
  return {
805
1011
  bucket: BUCKET,
@@ -835,7 +1041,9 @@ bucket_definitions:
835
1041
  await bucketStateCollection.insertOne({
836
1042
  _id: { d: definitionId, b: BUCKET },
837
1043
  last_op: 30n,
838
- estimate_since_compact: { count: 3, bytes: 100 }
1044
+ next_compact_check: undefined,
1045
+ first_uncompacted_write: undefined,
1046
+ bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
839
1047
  });
840
1048
 
841
1049
  const request = checksumRequest();
@@ -874,7 +1082,9 @@ bucket_definitions:
874
1082
  await bucketStateCollection.insertOne({
875
1083
  _id: { d: definitionId, b: BUCKET },
876
1084
  last_op: 0n,
877
- estimate_since_compact: { count: 0, bytes: 0 }
1085
+ next_compact_check: undefined,
1086
+ first_uncompacted_write: undefined,
1087
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
878
1088
  });
879
1089
 
880
1090
  const request = checksumRequest();
@@ -894,7 +1104,9 @@ bucket_definitions:
894
1104
  await bucketStateCollection.insertOne({
895
1105
  _id: { d: definitionId, b: BUCKET },
896
1106
  last_op: 0n,
897
- estimate_since_compact: { count: 0, bytes: 0 }
1107
+ next_compact_check: undefined,
1108
+ first_uncompacted_write: undefined,
1109
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
898
1110
  });
899
1111
 
900
1112
  const request = checksumRequest();
@@ -913,7 +1125,9 @@ bucket_definitions:
913
1125
  await bucketStateCollection.insertOne({
914
1126
  _id: { d: definitionId, b: BUCKET },
915
1127
  last_op: 10n,
916
- estimate_since_compact: { count: 1, bytes: 100 }
1128
+ next_compact_check: undefined,
1129
+ first_uncompacted_write: undefined,
1130
+ bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
917
1131
  });
918
1132
 
919
1133
  const request = checksumRequest();
@@ -926,7 +1140,7 @@ bucket_definitions:
926
1140
  await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
927
1141
  await bucketStateCollection.updateOne(
928
1142
  { _id: { d: definitionId, b: BUCKET } },
929
- { $set: { last_op: 30n, 'estimate_since_compact.count': 2 } }
1143
+ { $set: { last_op: 30n, 'bucket_stats.count': 2 } }
930
1144
  );
931
1145
 
932
1146
  const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
@@ -940,7 +1154,6 @@ bucket_definitions:
940
1154
 
941
1155
  describe('V3 compaction boundaries', () => {
942
1156
  const BUCKET = 'global[]';
943
- const TABLE = 'items';
944
1157
 
945
1158
  async function setupV3() {
946
1159
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -954,7 +1167,7 @@ bucket_definitions:
954
1167
  { storageVersion: 3 }
955
1168
  )
956
1169
  );
957
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1170
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
958
1171
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
959
1172
  const mapping = syncRules.syncConfigContent[0].mapping;
960
1173
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -971,31 +1184,6 @@ bucket_definitions:
971
1184
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
972
1185
  }
973
1186
 
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
1187
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1000
1188
  await collection.insertMany(docs);
1001
1189
  }
@@ -1004,7 +1192,9 @@ bucket_definitions:
1004
1192
  await bucketStateCollection.insertOne({
1005
1193
  _id: { d: definitionId, b: BUCKET },
1006
1194
  last_op: lastOp,
1007
- estimate_since_compact: { count: 10, bytes: 100 }
1195
+ next_compact_check: new Date(0),
1196
+ first_uncompacted_write: new Date(0),
1197
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1008
1198
  });
1009
1199
  }
1010
1200
 
@@ -1030,6 +1220,156 @@ bucket_definitions:
1030
1220
  return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1031
1221
  }
1032
1222
 
1223
+ test('scheduled compaction claims only due buckets and clears completed full work', async () => {
1224
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1225
+ const docs = [
1226
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
1227
+ serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
1228
+ ];
1229
+ await insertDocs(collection, docs);
1230
+ await bucketStateCollection.insertOne({
1231
+ _id: { d: ctx.definitionId, b: BUCKET },
1232
+ last_op: 2n,
1233
+ next_compact_check: new Date(Date.now() + 60_000),
1234
+ first_uncompacted_write: new Date(Date.now() - 60_000),
1235
+ bucket_stats: {
1236
+ count: 2,
1237
+ bytes: BigInt(docs[0].size + docs[1].size),
1238
+ chunks: 2
1239
+ }
1240
+ });
1241
+
1242
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1243
+ expect(
1244
+ (await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
1245
+ ).toBeUndefined();
1246
+
1247
+ await bucketStateCollection.updateOne(
1248
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1249
+ { $set: { next_compact_check: new Date(Date.now() - 1) } }
1250
+ );
1251
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1252
+
1253
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1254
+ expect(state?.last_full_compact?.op_id).toBe(2n);
1255
+ expect(state?.first_uncompacted_write).toBeUndefined();
1256
+ expect(state?.next_compact_check).toBeUndefined();
1257
+ expect(state?.compact_lease).toBeUndefined();
1258
+ });
1259
+
1260
+ test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
1261
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1262
+ const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
1263
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
1264
+ await insertDocs(collection, [prefix, untouchedTail]);
1265
+ await bucketStateCollection.insertOne({
1266
+ _id: { d: ctx.definitionId, b: BUCKET },
1267
+ last_op: 2n,
1268
+ next_compact_check: new Date(0),
1269
+ first_uncompacted_write: new Date(0),
1270
+ bucket_stats: {
1271
+ count: prefix.count + untouchedTail.count,
1272
+ bytes: BigInt(prefix.size + untouchedTail.size),
1273
+ chunks: 2
1274
+ }
1275
+ });
1276
+
1277
+ await bucketStorage.compact({ maxOpId: 1n });
1278
+
1279
+ const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1280
+ expect(partialState?.last_full_compact).toMatchObject({
1281
+ op_id: 1n,
1282
+ count: prefix.count,
1283
+ puts: 1
1284
+ });
1285
+ expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
1286
+ expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
1287
+ expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
1288
+ partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
1289
+ );
1290
+
1291
+ const lastFullCompactAt = partialState!.last_full_compact!.at;
1292
+ const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
1293
+ await bucketStateCollection.updateOne(
1294
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1295
+ { $set: { next_compact_check: new Date(0) } }
1296
+ );
1297
+
1298
+ await bucketStorage.compact({ maxOpId: 2n });
1299
+
1300
+ const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1301
+ expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1302
+ expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
1303
+ expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
1304
+
1305
+ // Even after the scheduling window has elapsed, a run with the old cap
1306
+ // must not scan and record the same full-compaction prefix again.
1307
+ await bucketStateCollection.updateOne(
1308
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1309
+ { $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
1310
+ );
1311
+
1312
+ await bucketStorage.compact({ maxOpId: 1n });
1313
+
1314
+ const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1315
+ expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1316
+ expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
1317
+ expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
1318
+ });
1319
+
1320
+ test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
1321
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1322
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1323
+ await insertDocs(collection, [document]);
1324
+ await bucketStateCollection.insertOne({
1325
+ _id: { d: ctx.definitionId, b: BUCKET },
1326
+ last_op: 1n,
1327
+ next_compact_check: undefined,
1328
+ first_uncompacted_write: undefined,
1329
+ compacted_state: {
1330
+ op_id: 1n,
1331
+ checksum: document.checksum,
1332
+ count: document.count,
1333
+ bytes: BigInt(document.size),
1334
+ chunks: 1,
1335
+ at: new Date()
1336
+ },
1337
+ last_full_compact: {
1338
+ op_id: 1n,
1339
+ count: document.count,
1340
+ puts: 1,
1341
+ at: new Date()
1342
+ },
1343
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
1344
+ });
1345
+
1346
+ await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
1347
+
1348
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1349
+ expect(state?.compact_lease).toBeUndefined();
1350
+ expect(state?.next_compact_check).toBeNull();
1351
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1352
+ });
1353
+
1354
+ test('concurrent scheduled compactors lease a bucket to one worker', async () => {
1355
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1356
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1357
+ await insertDocs(collection, [document]);
1358
+ await bucketStateCollection.insertOne({
1359
+ _id: { d: ctx.definitionId, b: BUCKET },
1360
+ last_op: 1n,
1361
+ next_compact_check: new Date(Date.now() - 1),
1362
+ first_uncompacted_write: new Date(0),
1363
+ bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
1364
+ });
1365
+
1366
+ await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
1367
+
1368
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1369
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1370
+ expect(state?.compact_lease).toBeUndefined();
1371
+ });
1372
+
1033
1373
  test('1. superseded ops become MOVE tombstones', async () => {
1034
1374
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1035
1375
  // Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
@@ -1207,7 +1547,6 @@ bucket_definitions:
1207
1547
 
1208
1548
  describe('V3 MOVE tombstone properties', () => {
1209
1549
  const BUCKET = 'global[]';
1210
- const TABLE = 'items';
1211
1550
 
1212
1551
  async function setupV3() {
1213
1552
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
@@ -1221,7 +1560,7 @@ bucket_definitions:
1221
1560
  { storageVersion: 3 }
1222
1561
  )
1223
1562
  );
1224
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1563
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
1225
1564
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1226
1565
  const mapping = syncRules.syncConfigContent[0].mapping;
1227
1566
  const definitionId = mapping.allBucketDefinitionIds()[0];
@@ -1238,31 +1577,6 @@ bucket_definitions:
1238
1577
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1239
1578
  }
1240
1579
 
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
1580
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1267
1581
  await collection.insertMany(docs);
1268
1582
  }
@@ -1271,7 +1585,9 @@ bucket_definitions:
1271
1585
  await bucketStateCollection.insertOne({
1272
1586
  _id: { d: definitionId, b: BUCKET },
1273
1587
  last_op: lastOp,
1274
- estimate_since_compact: { count: 10, bytes: 100 }
1588
+ next_compact_check: new Date(0),
1589
+ first_uncompacted_write: new Date(0),
1590
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1275
1591
  });
1276
1592
  }
1277
1593
 
@@ -1420,10 +1736,9 @@ bucket_definitions:
1420
1736
  */
1421
1737
  describe('Streaming compactor', () => {
1422
1738
  const BUCKET = 'global[]';
1423
- const TABLE = 'items';
1424
1739
 
1425
- async function setupV3() {
1426
- await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1740
+ async function setupV3(factoryGenerator = INITIALIZED_MONGO_STORAGE_FACTORY) {
1741
+ await using factory = await factoryGenerator.factory();
1427
1742
  const syncRules = await factory.updateSyncRules(
1428
1743
  updateSyncRulesFromYaml(
1429
1744
  `
@@ -1434,7 +1749,7 @@ bucket_definitions:
1434
1749
  { storageVersion: 3 }
1435
1750
  )
1436
1751
  );
1437
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1752
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorage;
1438
1753
  const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1439
1754
  const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
1440
1755
  const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
@@ -1450,31 +1765,6 @@ bucket_definitions:
1450
1765
  return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1451
1766
  }
1452
1767
 
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
1768
  async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1479
1769
  await collection.insertMany(docs);
1480
1770
  }
@@ -1483,7 +1773,9 @@ bucket_definitions:
1483
1773
  await bucketStateCollection.insertOne({
1484
1774
  _id: { d: definitionId, b: BUCKET },
1485
1775
  last_op: lastOp,
1486
- estimate_since_compact: { count: 10, bytes: 100 }
1776
+ next_compact_check: new Date(0),
1777
+ first_uncompacted_write: new Date(0),
1778
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1487
1779
  });
1488
1780
  }
1489
1781
 
@@ -1501,6 +1793,551 @@ bucket_definitions:
1501
1793
  );
1502
1794
  }
1503
1795
 
1796
+ async function setupCompactedTail() {
1797
+ const setup = await setupV3();
1798
+ const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
1799
+ const documents = [
1800
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1801
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1802
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1803
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1804
+ ];
1805
+ await insertDocs(collection, documents);
1806
+ await bucketStateCollection.insertOne({
1807
+ _id: { d: ctx.definitionId, b: BUCKET },
1808
+ last_op: 4n,
1809
+ next_compact_check: new Date(0),
1810
+ first_uncompacted_write: new Date(0),
1811
+ compacted_state: {
1812
+ op_id: 2n,
1813
+ checksum: documents[0].checksum + documents[1].checksum,
1814
+ count: documents[0].count + documents[1].count,
1815
+ bytes: BigInt(documents[0].size + documents[1].size),
1816
+ chunks: 2,
1817
+ at: new Date(0)
1818
+ },
1819
+ bucket_stats: {
1820
+ count: documents.reduce((total, document) => total + document.count, 0),
1821
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1822
+ chunks: documents.length
1823
+ }
1824
+ });
1825
+ return setup;
1826
+ }
1827
+
1828
+ async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
1829
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1830
+ expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
1831
+ 1n,
1832
+ 2n,
1833
+ 3n,
1834
+ 4n
1835
+ ]);
1836
+
1837
+ const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
1838
+ const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
1839
+ expect(state?.compacted_state).toMatchObject({
1840
+ op_id: 4n,
1841
+ checksum: 70n,
1842
+ count: 4,
1843
+ bytes,
1844
+ chunks: documents.length
1845
+ });
1846
+ expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
1847
+ }
1848
+
1849
+ test('initial compaction merges eight chunks immediately without full compaction', async () => {
1850
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1851
+ const inputs = Array.from({ length: 8 }, (_, index) =>
1852
+ serializeBucketData(BUCKET, [makeOp(index + 1, 'A', 'data', ctx, sourceTableId)])
1853
+ );
1854
+ await insertDocs(collection, inputs);
1855
+ await bucketStateCollection.insertOne({
1856
+ _id: { d: ctx.definitionId, b: BUCKET },
1857
+ last_op: 8n,
1858
+ next_compact_check: new Date(),
1859
+ first_uncompacted_write: new Date(0),
1860
+ bucket_stats: { count: 8, bytes: BigInt(inputs.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
1861
+ });
1862
+
1863
+ expect(await bucketStorage.compactInitialReplication({ maxOpId: 8n })).toEqual({ buckets: 1 });
1864
+ const documents = await collection.find({ '_id.b': BUCKET }).toArray();
1865
+ expect(documents).toHaveLength(1);
1866
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n]);
1867
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1868
+ expect(state?.compacted_state).toMatchObject({ op_id: 8n, count: 8, checksum: 252n });
1869
+ expect(state?.last_full_compact).toBeUndefined();
1870
+ });
1871
+
1872
+ test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
1873
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1874
+ await insertDocs(collection, [
1875
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1876
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
1877
+ ]);
1878
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
1879
+
1880
+ const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
1881
+
1882
+ expect(result).toEqual({ buckets: 1 });
1883
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1884
+ expect(documents).toHaveLength(1);
1885
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
1886
+
1887
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1888
+ expect(state?.compacted_state).toMatchObject({
1889
+ op_id: 4n,
1890
+ count: 4,
1891
+ checksum: 70n
1892
+ });
1893
+ });
1894
+
1895
+ test.each([
1896
+ { payloadSize: 100, workers: undefined, abort: false },
1897
+ { payloadSize: 450_000, workers: undefined, abort: false },
1898
+ { payloadSize: 450_000, workers: 4, abort: false },
1899
+ { payloadSize: 100, workers: 1, abort: false },
1900
+ { payloadSize: 450_000, workers: 4, abort: true }
1901
+ ])('chunk compaction shares configured workers across runs: %o', async ({ payloadSize, workers, abort }) => {
1902
+ const concurrency = workers ?? 4;
1903
+ const objectStorage = new MemoryObjectStorage();
1904
+ const generator = mongoTestStorageFactoryGenerator({
1905
+ url: env.MONGO_TEST_URL,
1906
+ isCI: env.CI,
1907
+ objectStorage,
1908
+ chunkCompactionConcurrency: workers,
1909
+ inlineThresholdBytes: 0
1910
+ });
1911
+ const { bucketStorage, syncRules, collection, bucketStateCollection, ctx, sourceTableId, db } =
1912
+ await setupV3(generator);
1913
+ const bucketCount = 12;
1914
+ for (let i = 0; i < bucketCount; i++) {
1915
+ const bucket = `global[${i}]`;
1916
+ const documents = [1, 2].map((id) =>
1917
+ serializeBucketData(bucket, [makeOp(id, String(id), 'x'.repeat(payloadSize), ctx, sourceTableId)])
1918
+ );
1919
+ await collection.insertMany(documents);
1920
+ await bucketStateCollection.insertOne({
1921
+ _id: { d: ctx.definitionId, b: bucket },
1922
+ last_op: 2n,
1923
+ next_compact_check: new Date(0),
1924
+ first_uncompacted_write: new Date(0),
1925
+ bucket_stats: {
1926
+ count: 2,
1927
+ bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)),
1928
+ chunks: 2
1929
+ }
1930
+ });
1931
+ }
1932
+
1933
+ const gate = Promise.withResolvers<void>();
1934
+ const originalPut = objectStorage.put.bind(objectStorage);
1935
+ let uploads = 0;
1936
+ const put = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
1937
+ uploads++;
1938
+ await gate.promise;
1939
+ await originalPut(...args);
1940
+ });
1941
+ const controller = new AbortController();
1942
+ let settled = false;
1943
+ const runs = Promise.allSettled(
1944
+ Array.from({ length: 2 }, () =>
1945
+ bucketStorage.factory
1946
+ .getInstance(syncRules)
1947
+ .compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n, signal: controller.signal })
1948
+ )
1949
+ ).finally(() => {
1950
+ settled = true;
1951
+ });
1952
+ try {
1953
+ await vi.waitFor(() => expect(uploads).toBe(concurrency));
1954
+ // Let other ready workers reach admission; the held uploads must prevent
1955
+ // any additional payloads from reaching S3, across both compactor instances.
1956
+ await new Promise((resolve) => setTimeout(resolve, 50));
1957
+ expect(uploads).toBe(concurrency);
1958
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBeLessThanOrEqual(
1959
+ concurrency
1960
+ );
1961
+ expect(await collection.countDocuments()).toBe(bucketCount * 2);
1962
+ if (abort) {
1963
+ controller.abort();
1964
+ await new Promise((resolve) => setTimeout(resolve, 50));
1965
+ expect(settled).toBe(false);
1966
+ }
1967
+ } finally {
1968
+ gate.resolve();
1969
+ await runs;
1970
+ put.mockRestore();
1971
+ }
1972
+
1973
+ const results = await runs;
1974
+ if (abort) {
1975
+ expect(results.every((result) => result.status === 'rejected')).toBe(true);
1976
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
1977
+ // Cancellation must return all worker slots.
1978
+ expect(await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 2n })).toEqual({
1979
+ buckets: bucketCount
1980
+ });
1981
+ } else {
1982
+ expect(results.every((result) => result.status === 'fulfilled')).toBe(true);
1983
+ expect(results.reduce((sum, result) => sum + (result.status === 'fulfilled' ? result.value.buckets : 0), 0)).toBe(
1984
+ bucketCount
1985
+ );
1986
+ }
1987
+
1988
+ const documents = await collection.find().toArray();
1989
+ expect(documents).toHaveLength(bucketCount);
1990
+ expect(documents.every((doc) => doc.count === 2 && doc.checksum === 21n && doc.storage_ref != null)).toBe(true);
1991
+ expect(
1992
+ await bucketStateCollection.countDocuments({ 'compacted_state.op_id': 2n, compact_lease: { $exists: false } })
1993
+ ).toBe(bucketCount);
1994
+ const usage = await db.objectStorageUsage.find({ '_id.g': ctx.replicationStreamId }).toArray();
1995
+ const activeBytes = usage.reduce((sum, doc) => sum + BigInt(doc.definitions[ctx.definitionId] ?? 0), 0n);
1996
+ expect(activeBytes).toBe(documents.reduce((sum, doc) => sum + BigInt(doc.storage_ref!.file_size), 0n));
1997
+ });
1998
+
1999
+ test.each([2, 3])('failed chunk worker stops queued work and drains siblings (%s workers)', async (workerCount) => {
2000
+ const { bucketStorage } = await setupV3();
2001
+ const compactor = bucketStorage.createMongoCompactor({}) as any;
2002
+ const failure = new Error('worker failed');
2003
+ const failing = Promise.withResolvers<void>();
2004
+ const draining = Promise.withResolvers<void>();
2005
+ const started: number[] = [];
2006
+ let settled = false;
2007
+ // The factory has two slots. With three workers, one also waits for a slot
2008
+ // when its sibling fails; it must release that slot without starting work.
2009
+ const run = compactor
2010
+ .runChunkCompactionWorkers(
2011
+ [0, 1, 2, 3, 4],
2012
+ Array.from({ length: workerCount }, () => compactor.objectStorageUsage),
2013
+ async (bucket: number) => {
2014
+ started.push(bucket);
2015
+ if (bucket === 0) await failing.promise;
2016
+ if (bucket === 1) await draining.promise;
2017
+ }
2018
+ )
2019
+ .then(
2020
+ () => undefined,
2021
+ (error: unknown) => error
2022
+ )
2023
+ .finally(() => {
2024
+ settled = true;
2025
+ });
2026
+ try {
2027
+ await vi.waitFor(() => expect(started).toEqual([0, 1]));
2028
+ failing.reject(failure);
2029
+ await new Promise<void>((resolve) => setImmediate(resolve));
2030
+ expect(started).toEqual([0, 1]);
2031
+ expect(settled).toBe(false);
2032
+ } finally {
2033
+ failing.resolve();
2034
+ draining.resolve();
2035
+ await run;
2036
+ }
2037
+ expect(await run).toBe(failure);
2038
+ expect(started).toEqual([0, 1]);
2039
+ expect(bucketStorage.factory.chunkCompactionSlots.getValue()).toBe(2);
2040
+ });
2041
+
2042
+ test('unforced chunk workers drain before full compaction and reclassify aged buckets on the next scan', async () => {
2043
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2044
+ const buckets = ['chunk1[]', 'chunk2[]', 'promoted[]', 'full1[]', 'full2[]'];
2045
+ for (const bucket of buckets) {
2046
+ const documents = Array.from({ length: 8 }, (_, index) =>
2047
+ serializeBucketData(bucket, [makeOp(index + 1, String(index), 'data', { ...ctx, bucket }, sourceTableId)])
2048
+ );
2049
+ await collection.insertMany(documents);
2050
+ await bucketStateCollection.insertOne({
2051
+ _id: { d: ctx.definitionId, b: bucket },
2052
+ last_op: 8n,
2053
+ next_compact_check: new Date(0),
2054
+ first_uncompacted_write: bucket.startsWith('full') ? new Date(0) : new Date(),
2055
+ bucket_stats: { count: 8, bytes: BigInt(documents.reduce((sum, doc) => sum + doc.size, 0)), chunks: 8 }
2056
+ });
2057
+ }
2058
+
2059
+ const originalClaim = CompactionLease.claim.bind(CompactionLease);
2060
+ const jobStartedAt = new Date();
2061
+ let serverTime = jobStartedAt;
2062
+ let promoted = false;
2063
+ const claim = vi.spyOn(CompactionLease, 'claim').mockImplementation(async (...args) => {
2064
+ if (!promoted && (args[1]._id as { b: string })?.b === 'promoted[]') {
2065
+ promoted = true;
2066
+ // Advance the server clock past the full-compaction interval without
2067
+ // changing bucket state. A fixed scan timestamp would retry forever.
2068
+ serverTime = new Date(jobStartedAt.getTime() + 3 * 60 * 60 * 1000);
2069
+ }
2070
+ const lease = await originalClaim(...args);
2071
+ if (lease != null) {
2072
+ Object.defineProperty(lease, 'startedAt', { value: serverTime });
2073
+ }
2074
+ return lease;
2075
+ });
2076
+
2077
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 8n });
2078
+ const internal = compactor as any;
2079
+ let timeReads = 0;
2080
+ const clock = vi.spyOn(internal, 'readCompactionTime').mockImplementation(async () => {
2081
+ if (++timeReads > 5) {
2082
+ throw new Error('Compaction did not advance after the bucket aged');
2083
+ }
2084
+ return serverTime;
2085
+ });
2086
+ const scan = vi.spyOn(internal, 'findScheduledBucketBatch');
2087
+ const originalChunks = internal.compactSingleBucketChunks.bind(compactor);
2088
+ const originalFull = internal.compactSingleBucketFully.bind(compactor);
2089
+ const gate = Promise.withResolvers<void>();
2090
+ let activeChunks = 0;
2091
+ let activeFull = 0;
2092
+ let maxActiveFull = 0;
2093
+ const fullBuckets: string[] = [];
2094
+ const chunks = vi.spyOn(internal, 'compactSingleBucketChunks').mockImplementation(async (...args) => {
2095
+ activeChunks++;
2096
+ try {
2097
+ await gate.promise;
2098
+ return await originalChunks(...args);
2099
+ } finally {
2100
+ activeChunks--;
2101
+ }
2102
+ });
2103
+ const full = vi.spyOn(internal, 'compactSingleBucketFully').mockImplementation(async (...args) => {
2104
+ expect(activeChunks).toBe(0);
2105
+ activeFull++;
2106
+ maxActiveFull = Math.max(maxActiveFull, activeFull);
2107
+ fullBuckets.push((args[0] as any).state._id.b);
2108
+ try {
2109
+ return await originalFull(...args);
2110
+ } finally {
2111
+ activeFull--;
2112
+ }
2113
+ });
2114
+ const run = compactor.compact();
2115
+ try {
2116
+ await vi.waitFor(() => expect(activeChunks).toBe(2));
2117
+ expect(full).not.toHaveBeenCalled();
2118
+ } finally {
2119
+ gate.resolve();
2120
+ try {
2121
+ await run;
2122
+ expect(timeReads).toBe(3); // Job start, first batch, and promoted bucket's batch.
2123
+ expect(scan).toHaveBeenCalledTimes(3); // Includes the final empty scan.
2124
+ for (const [cutoff] of scan.mock.calls) {
2125
+ expect(cutoff).toEqual(jobStartedAt);
2126
+ }
2127
+ } finally {
2128
+ clock.mockRestore();
2129
+ scan.mockRestore();
2130
+ claim.mockRestore();
2131
+ chunks.mockRestore();
2132
+ full.mockRestore();
2133
+ }
2134
+ }
2135
+ expect(await run).toBe(5);
2136
+ expect(promoted).toBe(true);
2137
+ expect(maxActiveFull).toBe(1);
2138
+ expect(fullBuckets.sort()).toEqual(['full1[]', 'full2[]', 'promoted[]']);
2139
+ expect(await bucketStateCollection.countDocuments({ compact_lease: { $exists: true } })).toBe(0);
2140
+ expect(await collection.countDocuments()).toBe(5);
2141
+ });
2142
+
2143
+ test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
2144
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2145
+ const operations = [
2146
+ makeOp(1, 'A', 'a', ctx, sourceTableId),
2147
+ makeOp(2, 'B', 'b', ctx, sourceTableId),
2148
+ makeOp(3, 'C', 'c', ctx, sourceTableId),
2149
+ makeOp(4, 'D', 'd', ctx, sourceTableId)
2150
+ ];
2151
+ const originalDocuments = [
2152
+ serializeBucketData(BUCKET, operations.slice(0, 2)),
2153
+ serializeBucketData(BUCKET, operations.slice(2))
2154
+ ];
2155
+ const committedMerge = serializeBucketData(BUCKET, operations);
2156
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
2157
+ await insertDocs(collection, [committedMerge, untouchedTail]);
2158
+ await bucketStateCollection.insertOne({
2159
+ _id: { d: ctx.definitionId, b: BUCKET },
2160
+ last_op: 5n,
2161
+ next_compact_check: new Date(0),
2162
+ first_uncompacted_write: new Date(0),
2163
+ bucket_stats: {
2164
+ count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
2165
+ bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
2166
+ chunks: originalDocuments.length + 1
2167
+ }
2168
+ });
2169
+
2170
+ const result = await bucketStorage.compactInitialReplication({ forceChunkCompaction: true, maxOpId: 4n });
2171
+
2172
+ expect(result).toEqual({ buckets: 1 });
2173
+ const expectedStats = {
2174
+ count: committedMerge.count + untouchedTail.count,
2175
+ bytes: BigInt(committedMerge.size + untouchedTail.size),
2176
+ chunks: 2
2177
+ };
2178
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2179
+ expect(state?.bucket_stats).toEqual(expectedStats);
2180
+ expect(state?.compacted_state).toMatchObject({
2181
+ op_id: 4n,
2182
+ checksum: committedMerge.checksum,
2183
+ count: committedMerge.count,
2184
+ bytes: BigInt(committedMerge.size),
2185
+ chunks: 1
2186
+ });
2187
+ });
2188
+
2189
+ test('later full compaction claims fresh state after a committed replacement', async () => {
2190
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2191
+ const documents = [
2192
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
2193
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
2194
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
2195
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
2196
+ ];
2197
+ await insertDocs(collection, documents);
2198
+ await bucketStateCollection.insertOne({
2199
+ _id: { d: ctx.definitionId, b: BUCKET },
2200
+ last_op: 4n,
2201
+ next_compact_check: new Date(0),
2202
+ first_uncompacted_write: new Date(0),
2203
+ bucket_stats: {
2204
+ count: documents.reduce((total, document) => total + document.count, 0),
2205
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
2206
+ chunks: documents.length
2207
+ }
2208
+ });
2209
+
2210
+ const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
2211
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
2212
+ const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
2213
+ let injectedFailure = false;
2214
+ vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
2215
+ const result = await originalFlush(...args);
2216
+ if (!injectedFailure) {
2217
+ injectedFailure = true;
2218
+ await insertDocs(collection, [concurrentDocument]);
2219
+ await bucketStateCollection.updateOne(
2220
+ { _id: { d: ctx.definitionId, b: BUCKET } },
2221
+ {
2222
+ $set: { last_op: 5n },
2223
+ $inc: {
2224
+ 'bucket_stats.count': concurrentDocument.count,
2225
+ 'bucket_stats.bytes': BigInt(concurrentDocument.size),
2226
+ 'bucket_stats.chunks': 1
2227
+ }
2228
+ }
2229
+ );
2230
+ throw new ObjectStorageError('failure after committed full-compaction replacement', {
2231
+ cause: new Error('socket reset'),
2232
+ retryable: true
2233
+ });
2234
+ }
2235
+ return result;
2236
+ });
2237
+
2238
+ await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
2239
+
2240
+ expect(injectedFailure).toBe(true);
2241
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2242
+ expect(interruptedState?.last_op).toBe(5n);
2243
+ expect(interruptedState?.compacted_state).toBeUndefined();
2244
+ expect(interruptedState?.compact_lease).toBeUndefined();
2245
+
2246
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
2247
+ 1
2248
+ );
2249
+
2250
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
2251
+ expect(currentDocuments).toHaveLength(1);
2252
+ const expectedStats = {
2253
+ count: currentDocuments.reduce((total, document) => total + document.count, 0),
2254
+ bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
2255
+ chunks: currentDocuments.length
2256
+ };
2257
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2258
+ expect(state?.bucket_stats).toEqual(expectedStats);
2259
+ expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
2260
+ expect(state?.last_full_compact?.op_id).toBe(5n);
2261
+ expect(state?.first_uncompacted_write).toBeUndefined();
2262
+ expect(state?.next_compact_check).toBeUndefined();
2263
+ });
2264
+
2265
+ test('chunk compaction treats a missing cached op as a resume hint', async () => {
2266
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2267
+ const documents = [
2268
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
2269
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
2270
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
2271
+ ];
2272
+ await insertDocs(collection, documents);
2273
+ await bucketStateCollection.insertOne({
2274
+ _id: { d: ctx.definitionId, b: BUCKET },
2275
+ last_op: 4n,
2276
+ next_compact_check: new Date(0),
2277
+ first_uncompacted_write: new Date(0),
2278
+ compacted_state: {
2279
+ op_id: 2n,
2280
+ checksum: 21n,
2281
+ count: 2,
2282
+ bytes: 100n,
2283
+ chunks: 2,
2284
+ at: new Date(0)
2285
+ },
2286
+ bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
2287
+ });
2288
+
2289
+ await expect(
2290
+ bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true }).compact()
2291
+ ).resolves.toBe(1);
2292
+
2293
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2294
+ expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
2295
+ const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
2296
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2297
+ expect(state?.compacted_state).toMatchObject({
2298
+ op_id: 4n,
2299
+ checksum: 56n,
2300
+ count: 3,
2301
+ bytes,
2302
+ chunks: currentDocuments.length
2303
+ });
2304
+ expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
2305
+ });
2306
+
2307
+ test('later chunk compaction rebuilds state after a committed partial merge', async () => {
2308
+ const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
2309
+ const failedCompactor = bucketStorage.createMongoCompactor({
2310
+ maxOpId: 4n,
2311
+ compactChunksOnly: true,
2312
+ forceChunkCompaction: true
2313
+ });
2314
+ const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
2315
+ const flushSpy = vi
2316
+ .spyOn(failedCompactor as any, 'flushCompactionGroup')
2317
+ .mockImplementation(async (...args: any[]) => {
2318
+ const result = await originalFlush(...args);
2319
+ throw new Error('failure after committed chunk merge');
2320
+ });
2321
+
2322
+ await expect(failedCompactor.compact()).resolves.toBe(0);
2323
+ flushSpy.mockRestore();
2324
+
2325
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2326
+ expect(interruptedState?.compacted_state?.op_id).toBe(2n);
2327
+ expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
2328
+ await bucketStateCollection.updateOne(
2329
+ { _id: { d: ctx.definitionId, b: BUCKET } },
2330
+ { $set: { next_compact_check: new Date(0) } }
2331
+ );
2332
+
2333
+ const result = await bucketStorage
2334
+ .createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true, forceChunkCompaction: true })
2335
+ .compact();
2336
+
2337
+ expect(result).toBe(1);
2338
+ await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
2339
+ });
2340
+
1504
2341
  test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
1505
2342
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1506
2343
 
@@ -1616,6 +2453,13 @@ bucket_definitions:
1616
2453
  expect(op600).toBeDefined();
1617
2454
  expect(op600!.op).toBe('PUT');
1618
2455
  expect(op600!.row_id).toBe('F');
2456
+
2457
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2458
+ expect(state?.bucket_stats).toEqual({
2459
+ count: docsAfter.reduce((total, document) => total + document.count, 0),
2460
+ bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
2461
+ chunks: docsAfter.length
2462
+ });
1619
2463
  });
1620
2464
 
1621
2465
  test('3. seen map overflow - some old ops pass through without tombstoning', async () => {