@powersync/service-module-mongodb-storage 0.21.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 (81) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -0
  3. package/dist/storage/MongoBucketStorage.js +7 -1
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +2 -1
  6. package/dist/storage/implementation/MongoBucketBatch.js +9 -8
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoStorageProvider.js +4 -2
  9. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  10. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  11. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  12. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  13. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
  14. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  15. package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
  16. package/dist/storage/implementation/common/PersistedBatch.js +6 -4
  17. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  18. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  19. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  20. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  21. package/dist/storage/implementation/db.d.ts +5 -0
  22. package/dist/storage/implementation/db.js +13 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +13 -6
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  26. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
  27. package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
  28. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  29. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  30. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  31. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +2 -1
  33. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +17 -14
  34. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +6 -1
  36. package/dist/storage/implementation/v3/MongoCompactorV3.js +164 -69
  37. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  38. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
  39. package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
  40. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  41. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
  42. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  43. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
  44. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
  45. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  46. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
  47. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
  48. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
  49. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
  50. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  51. package/dist/types/types.d.ts +5 -0
  52. package/dist/types/types.js +12 -0
  53. package/dist/types/types.js.map +1 -1
  54. package/dist/utils/test-utils.js +1 -0
  55. package/dist/utils/test-utils.js.map +1 -1
  56. package/package.json +6 -6
  57. package/src/storage/MongoBucketStorage.ts +12 -1
  58. package/src/storage/implementation/MongoBucketBatch.ts +13 -10
  59. package/src/storage/implementation/MongoStorageProvider.ts +12 -2
  60. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  61. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
  62. package/src/storage/implementation/common/PersistedBatch.ts +12 -8
  63. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  64. package/src/storage/implementation/db.ts +14 -0
  65. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +17 -8
  66. package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
  67. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  68. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +51 -49
  69. package/src/storage/implementation/v3/MongoCompactorV3.ts +276 -97
  70. package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
  71. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
  72. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
  73. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
  74. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
  75. package/src/types/types.ts +18 -0
  76. package/src/utils/test-utils.ts +1 -0
  77. package/test/src/chunk_compaction_config.test.ts +38 -0
  78. package/test/src/object_storage_usage.test.ts +16 -4
  79. package/test/src/storage_compacting.test.ts +257 -5
  80. package/test/src/storage_s3_reading.test.ts +47 -1
  81. package/tsconfig.tsbuildinfo +1 -1
@@ -13,6 +13,7 @@ import {
13
13
  UpsertCurrentDataOptions
14
14
  } from '../common/PersistedBatch.js';
15
15
  import { SourceRecordLookupState } from '../common/SourceRecordStore.js';
16
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
16
17
  import { serializeBucketData } from './bucket-format.js';
17
18
  import { chunkBucketData } from './chunking.js';
18
19
  import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from './compaction-constants.js';
@@ -28,8 +29,15 @@ import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.
28
29
  import { ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
29
30
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
30
31
 
32
+ interface SourceRecordWrite {
33
+ sourceTableId: bson.ObjectId;
34
+ operation: mongo.AnyBulkWriteOperation<CurrentDataDocumentV3>;
35
+ }
36
+
31
37
  export class PersistedBatchV3 extends PersistedBatch {
32
- currentData: { sourceTableId: bson.ObjectId; operation: mongo.AnyBulkWriteOperation<CurrentDataDocumentV3> }[] = [];
38
+ // Upserts and soft deletes supply the complete source-record state, including
39
+ // pending_delete. Keep the final state per key, without removing any history.
40
+ currentData = new Map<string, SourceRecordWrite>();
33
41
  sourceTablePendingDeletes = new Map<string, InternalOpId>();
34
42
  protected readonly objectStorageLifecycle?: ObjectStorageLifecycle;
35
43
  protected readonly objectStorageUsage?: ObjectStorageUsage;
@@ -131,7 +139,7 @@ export class PersistedBatchV3 extends PersistedBatch {
131
139
  }
132
140
 
133
141
  hardDeleteCurrentData(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId) {
134
- this.currentData.push({
142
+ this.currentData.set(this.currentDataKey(sourceTableId, replicaId), {
135
143
  sourceTableId,
136
144
  operation: {
137
145
  deleteOne: {
@@ -147,7 +155,7 @@ export class PersistedBatchV3 extends PersistedBatch {
147
155
  replicaId: storage.ReplicaId,
148
156
  checkpointGreaterThan: InternalOpId
149
157
  ) {
150
- this.currentData.push({
158
+ this.currentData.set(this.currentDataKey(sourceTableId, replicaId), {
151
159
  sourceTableId,
152
160
  operation: {
153
161
  updateOne: {
@@ -195,7 +203,7 @@ export class PersistedBatchV3 extends PersistedBatch {
195
203
  };
196
204
  });
197
205
 
198
- this.currentData.push({
206
+ this.currentData.set(this.currentDataKey(values.sourceTableId, values.replicaId), {
199
207
  sourceTableId: values.sourceTableId,
200
208
  operation: {
201
209
  updateOne: {
@@ -216,12 +224,12 @@ export class PersistedBatchV3 extends PersistedBatch {
216
224
  }
217
225
 
218
226
  protected get currentDataCount() {
219
- return this.currentData.length;
227
+ return this.currentData.size;
220
228
  }
221
229
 
222
230
  // Flush methods
223
231
 
224
- protected async flushBucketData(session: mongo.ClientSession) {
232
+ protected async queueBucketData(writes: MongoWriteBatch) {
225
233
  const operationsByDefinition = new Map<BucketDefinitionId, BucketDataDoc[]>();
226
234
  for (const document of this.bucketData) {
227
235
  const existing = operationsByDefinition.get(document.bucketKey.definitionId) ?? [];
@@ -284,10 +292,10 @@ export class PersistedBatchV3 extends PersistedBatch {
284
292
  // S3ObjectStorage applies one shared concurrency limit across all callers,
285
293
  // so replication can schedule its uploads together without creating a
286
294
  // separate limiter here.
287
- const writes = await createAllInserts();
295
+ const bucketWrites = await createAllInserts();
288
296
 
289
297
  const usageDeltas = this.objectStorageUsage == null ? undefined : new Map<BucketDefinitionId, bigint>();
290
- for (const { definitionId, inserts } of writes) {
298
+ for (const { definitionId, inserts } of bucketWrites) {
291
299
  if (usageDeltas != null) {
292
300
  const delta = inserts.reduce(
293
301
  (sum, operation) => sum + ObjectStorageUsage.bytes(operation.insertOne.document),
@@ -298,18 +306,15 @@ export class PersistedBatchV3 extends PersistedBatch {
298
306
  }
299
307
  }
300
308
  if (inserts.length > 0) {
301
- await this.db.bucketData(this.group_id, definitionId).bulkWrite(inserts, {
302
- session,
303
- ordered: false
304
- });
309
+ writes.bulkWriteUnordered(this.db.bucketData(this.group_id, definitionId), inserts);
305
310
  }
306
311
  }
307
312
  if (usageDeltas != null) {
308
- await this.objectStorageUsage!.applyDeltas(usageDeltas, session);
313
+ this.objectStorageUsage!.applyDeltas(usageDeltas, writes);
309
314
  }
310
315
  }
311
316
 
312
- protected async flushBucketParameters(session: mongo.ClientSession) {
317
+ protected queueBucketParameters(writes: MongoWriteBatch): void {
313
318
  const operationsByIndex = new Map<string, typeof this.bucketParameters>();
314
319
  for (const document of this.bucketParameters) {
315
320
  const existing = operationsByIndex.get(document.index) ?? [];
@@ -318,23 +323,20 @@ export class PersistedBatchV3 extends PersistedBatch {
318
323
  }
319
324
 
320
325
  for (const [indexId, documents] of operationsByIndex.entries()) {
321
- await this.db.parameterIndex(this.group_id, indexId).bulkWrite(
326
+ writes.bulkWriteUnordered(
327
+ this.db.parameterIndex(this.group_id, indexId),
322
328
  documents.map((document) => ({
323
329
  insertOne: {
324
330
  document: taggedBucketParameterDocumentToTagged(document)
325
331
  }
326
- })),
327
- {
328
- session,
329
- ordered: false
330
- }
332
+ }))
331
333
  );
332
334
  }
333
335
  }
334
336
 
335
- protected async flushCurrentData(session: mongo.ClientSession) {
336
- const operationsBySourceTable = new Map<string, typeof this.currentData>();
337
- for (const operation of this.currentData) {
337
+ protected queueCurrentData(writes: MongoWriteBatch): void {
338
+ const operationsBySourceTable = new Map<string, SourceRecordWrite[]>();
339
+ for (const operation of this.currentData.values()) {
338
340
  const sourceTableId = operation.sourceTableId.toHexString();
339
341
  const existing = operationsBySourceTable.get(sourceTableId) ?? [];
340
342
  existing.push(operation);
@@ -357,33 +359,31 @@ export class PersistedBatchV3 extends PersistedBatch {
357
359
  });
358
360
 
359
361
  if (sourceTableUpdates.length > 0) {
360
- await this.db.sourceTables(this.group_id).bulkWrite(sourceTableUpdates, { session, ordered: false });
362
+ writes.bulkWriteUnordered(this.db.sourceTables(this.group_id), sourceTableUpdates);
361
363
  }
362
364
 
363
365
  for (const operations of operationsBySourceTable.values()) {
364
366
  const sourceTableId = operations[0]!.sourceTableId;
365
- await this.db.sourceRecords(this.group_id, sourceTableId).bulkWrite(
366
- operations.map((entry) => entry.operation),
367
- {
368
- session,
369
- ordered: true
370
- }
367
+ writes.bulkWriteUnordered(
368
+ this.db.sourceRecords(this.group_id, sourceTableId),
369
+ operations.map((entry) => entry.operation)
371
370
  );
372
371
  }
373
372
  }
374
373
 
375
- protected async flushBucketStates(session: mongo.ClientSession) {
376
- await this.db.bucketState(this.group_id).bulkWrite(this.getBucketStateUpdates(), {
377
- session,
378
- ordered: false
379
- });
374
+ protected queueBucketStates(writes: MongoWriteBatch): void {
375
+ writes.bulkWriteUnordered(this.db.bucketState(this.group_id), this.getBucketStateUpdates());
380
376
  }
381
377
 
382
378
  protected resetCurrentData() {
383
- this.currentData = [];
379
+ this.currentData.clear();
384
380
  this.sourceTablePendingDeletes.clear();
385
381
  }
386
382
 
383
+ private currentDataKey(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId): string {
384
+ return Buffer.from(bson.serialize({ t: sourceTableId, k: replicaId })).toString('base64');
385
+ }
386
+
387
387
  private getBucketStateUpdates(): mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[] {
388
388
  return Array.from(this.bucketStates.values()).map((state: BucketStateUpdate) => {
389
389
  if (state.definitionId == null) {
@@ -54,9 +54,16 @@ export async function hydrateBucketDataDocuments(
54
54
  return;
55
55
  }
56
56
  using _ = options?.tracer?.span('s3', 'read');
57
- await Promise.all(
57
+ const results = await Promise.allSettled(
58
58
  storedDocuments.map(async (document) => {
59
59
  document.ops = await store.retrieve(document.storage_ref!.path, { signal: options.signal });
60
60
  })
61
61
  );
62
+ // Drain every download before propagating failure so callers can release their
63
+ // worker slot and payloads without a sibling download hydrating a document later.
64
+ for (const result of results) {
65
+ if (result.status === 'rejected') {
66
+ throw result.reason;
67
+ }
68
+ }
62
69
  }
@@ -1,7 +1,7 @@
1
- import { mongo } from '@powersync/lib-service-mongodb';
2
1
  import { Logger } from '@powersync/lib-services-framework';
3
2
  import * as bson from 'bson';
4
3
  import { createHash, randomUUID } from 'node:crypto';
4
+ import { MongoWriteBatch } from '../../MongoWriteBatch.js';
5
5
  import { ObjectStorageDeletionMarker } from '../models.js';
6
6
  import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
7
7
  import { BucketDataObjectStorage } from './BucketDataObjectStorage.js';
@@ -93,7 +93,7 @@ export class ObjectStorageLifecycle {
93
93
  *
94
94
  * Call this in the same transaction as the one creating the references.
95
95
  */
96
- async publishUploads(uploads: PreparedObjectStorageUpload[], session: mongo.ClientSession): Promise<void> {
96
+ publishUploads(uploads: PreparedObjectStorageUpload[], writes: MongoWriteBatch): void {
97
97
  for (const upload of uploads) {
98
98
  if (!this.canPublish(upload)) {
99
99
  throw new Error(`Object storage publication lease expired for ${upload.path}`);
@@ -103,12 +103,13 @@ export class ObjectStorageLifecycle {
103
103
  return;
104
104
  }
105
105
 
106
- const result = await this.db
107
- .pendingObjectStorageDeletes(this.replicationStreamId)
108
- .deleteMany({ _id: { $in: uploads.map((upload) => upload.markerId) } }, { session });
109
- if (result.deletedCount !== uploads.length) {
110
- throw new Error(`Missing object storage publication markers`);
111
- }
106
+ writes.deleteMany(
107
+ this.db.pendingObjectStorageDeletes(this.replicationStreamId),
108
+ { _id: { $in: uploads.map((upload) => upload.markerId) } },
109
+ (deletedCount) => {
110
+ if (deletedCount !== uploads.length) throw new Error('Missing object storage publication markers');
111
+ }
112
+ );
112
113
  }
113
114
 
114
115
  /**
@@ -122,14 +123,14 @@ export class ObjectStorageLifecycle {
122
123
  *
123
124
  * Call this in the same transaction as the one removing the references to these files.
124
125
  */
125
- async retire(paths: Iterable<string>, session: mongo.ClientSession, now = new Date()): Promise<void> {
126
+ retire(paths: Iterable<string>, writes: MongoWriteBatch, now = new Date()): void {
126
127
  const markers: ObjectStorageDeletionMarker[] = Array.from(paths, (path) => ({
127
128
  _id: new bson.ObjectId(),
128
129
  path,
129
130
  delete_after: new Date(now.getTime() + OBJECT_STORAGE_REFERENCE_GRACE_MS)
130
131
  }));
131
132
  if (markers.length) {
132
- await this.db.pendingObjectStorageDeletes(this.replicationStreamId).insertMany(markers, { session });
133
+ writes.insertMany(this.db.pendingObjectStorageDeletes(this.replicationStreamId), markers);
133
134
  }
134
135
  }
135
136
 
@@ -3,6 +3,7 @@ import { mongo } from '@powersync/lib-service-mongodb';
3
3
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
4
4
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
5
  import { randomUUID } from 'node:crypto';
6
+ import { MongoWriteBatch } from '../../MongoWriteBatch.js';
6
7
  import { BucketDataDocumentV3, ObjectStorageUsageDocument } from '../models.js';
7
8
  import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
8
9
 
@@ -93,11 +94,11 @@ export class ObjectStorageUsage {
93
94
  });
94
95
  }
95
96
 
96
- async applyDelta(definitionId: BucketDefinitionId, delta: bigint, session: mongo.ClientSession): Promise<void> {
97
- await this.applyDeltas(new Map([[definitionId, delta]]), session);
97
+ applyDelta(definitionId: BucketDefinitionId, delta: bigint, writes: MongoWriteBatch): void {
98
+ this.applyDeltas(new Map([[definitionId, delta]]), writes);
98
99
  }
99
100
 
100
- async applyDeltas(deltas: ReadonlyMap<BucketDefinitionId, bigint>, session: mongo.ClientSession): Promise<void> {
101
+ applyDeltas(deltas: ReadonlyMap<BucketDefinitionId, bigint>, writes: MongoWriteBatch): void {
101
102
  if (this.writerId == null) {
102
103
  throw new ReplicationAssertionError('A writer id is required to apply object-storage usage');
103
104
  }
@@ -113,13 +114,11 @@ export class ObjectStorageUsage {
113
114
  return;
114
115
  }
115
116
 
116
- await this.db.objectStorageUsage.updateOne(
117
+ writes.updateOne(
118
+ this.db.objectStorageUsage,
117
119
  { _id: this.documentId() },
118
- {
119
- $inc: increments,
120
- $currentDate: { updated_at: true }
121
- },
122
- { upsert: true, session }
120
+ { $inc: increments, $currentDate: { updated_at: true } },
121
+ { upsert: true }
123
122
  );
124
123
  }
125
124
 
@@ -19,10 +19,13 @@ import {
19
19
  type ObjectStoragePutMetadata
20
20
  } from './ObjectStorage.js';
21
21
 
22
- const DEFAULT_S3_OPERATION_CONCURRENCY = 16;
22
+ // Never statically import (or re-export) this module!
23
+ // We want to avoid loading the AWS SDK unless the user has configured S3 object storage, since it's a large dependency.
24
+
25
+ const DEFAULT_S3_OPERATION_CONCURRENCY = 64;
23
26
  const S3_DELETE_PREFIX_BATCH_SIZE = 1000;
24
27
  /**
25
- * Slightly smaller than DEFAULT_S3_OPERATION_CONCURRENCY.
28
+ * Keep prefix deletion below the default shared limit to leave capacity for other operations.
26
29
  */
27
30
  const S3_DELETE_PREFIX_CONCURRENCY = 12;
28
31
  const MAX_S3_PREFIX_BYTES = 256;
@@ -54,12 +57,20 @@ const SAFE_S3_KEY_BYTES = 896;
54
57
  const BASELINE_CONNECTION_TIMEOUT_MS = 3_100;
55
58
  const REQUEST_TIMEOUT_FACTOR = 5;
56
59
  const OPERATION_TIMEOUT_FACTOR = 2;
60
+ /**
61
+ * Don't keep idle http sockets around for longer than this timeout.
62
+ *
63
+ * This covers the case where we have large bursts of activity during replication, followed by
64
+ * long periods of inactivity.
65
+ */
66
+ const SOCKET_IDLE_TIMEOUT_MS = 60_000;
67
+
57
68
  /**
58
69
  * How long an operation may wait for a concurrency slot, as a multiple of the operation timeout.
59
70
  *
60
71
  * Every slot holder now releases within the operation timeout, so the queue always drains: this is
61
72
  * a backpressure limit rather than a deadlock guard. Waiting this long means at least
62
- * `factor * concurrencyLimit` operations ahead of us each took their full deadline - 64 at the
73
+ * `factor * concurrencyLimit` operations ahead of us each took their full deadline - 256 at the
63
74
  * default concurrency. Real operations complete in a fraction of the deadline, so the number of
64
75
  * queued operations this actually tolerates is far higher, well above the ~2000 uploads that the
65
76
  * largest possible replication flush (MAX_TRANSACTION_DOC_COUNT) can enqueue at once.
@@ -221,6 +232,15 @@ export class S3ObjectStorage implements ObjectStorage {
221
232
  forcePathStyle: options.forcePathStyle,
222
233
  defaultsMode,
223
234
  requestHandler: new NodeHttpHandler({
235
+ // Match the shared request limit instead of queuing again at the HTTP pool.
236
+ httpAgent: {
237
+ keepAlive: true,
238
+ maxSockets: concurrencyLimit,
239
+ // The timeout here is a little more general than an "idle timeout", but it does cover the
240
+ // case of closing idle sockets after a period of inactivity.
241
+ timeout: SOCKET_IDLE_TIMEOUT_MS
242
+ },
243
+ httpsAgent: { keepAlive: true, maxSockets: concurrencyLimit, timeout: SOCKET_IDLE_TIMEOUT_MS },
224
244
  connectionTimeout: this.timeouts.connectionTimeoutMs,
225
245
  requestTimeout: this.timeouts.requestTimeoutMs,
226
246
  // Without this, exceeding requestTimeout only logs a warning.
@@ -65,6 +65,9 @@ export const MongoStorageConfig = service_types.configFile.BaseStorageConfig.and
65
65
  */
66
66
  clear_batch_throttle_rate: t.number.optional(),
67
67
 
68
+ /** Maximum concurrent buckets across V3 chunk-compaction jobs. Range: 1–64. Default: 4 with object storage, otherwise 2. */
69
+ chunk_compaction_concurrency: t.number.optional(),
70
+
68
71
  object_storage: S3ObjectStorageConfig.optional()
69
72
  })
70
73
  );
@@ -73,6 +76,21 @@ export type MongoStorageConfig = t.Encoded<typeof MongoStorageConfig>;
73
76
  export type MongoStorageConfigDecoded = t.Decoded<typeof MongoStorageConfig>;
74
77
 
75
78
  export const DEFAULT_CLEAR_BATCH_THROTTLE_RATE = 0.2;
79
+ export const DEFAULT_CHUNK_COMPACTION_CONCURRENCY = 2;
80
+ export const DEFAULT_OBJECT_STORAGE_CHUNK_COMPACTION_CONCURRENCY = 4;
81
+
82
+ export function normalizeChunkCompactionConcurrency(value: number | undefined, hasObjectStorage = false): number {
83
+ const concurrency =
84
+ value ??
85
+ (hasObjectStorage ? DEFAULT_OBJECT_STORAGE_CHUNK_COMPACTION_CONCURRENCY : DEFAULT_CHUNK_COMPACTION_CONCURRENCY);
86
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) {
87
+ throw new ServiceError(
88
+ ErrorCode.PSYNC_S3201,
89
+ 'storage.chunk_compaction_concurrency must be an integer between 1 and 64'
90
+ );
91
+ }
92
+ return concurrency;
93
+ }
76
94
 
77
95
  export function normalizeClearBatchThrottleRate(value: number | undefined): number {
78
96
  const rate = value ?? DEFAULT_CLEAR_BATCH_THROTTLE_RATE;
@@ -31,6 +31,7 @@ export function mongoTestStorageFactoryGenerator(factoryOptions: MongoTestStorag
31
31
  checksumOptions: factoryOptions.checksumOptions,
32
32
  supportsMultipleSyncConfigs: factoryOptions.supportsMultipleSyncConfigs,
33
33
  objectStorage: factoryOptions.objectStorage,
34
+ chunkCompactionConcurrency: factoryOptions.chunkCompactionConcurrency,
34
35
  inlineThresholdBytes: factoryOptions.inlineThresholdBytes
35
36
  });
36
37
  },
@@ -0,0 +1,38 @@
1
+ import { MongoStorageConfig, normalizeChunkCompactionConcurrency } from '@module/types/types.js';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ describe('chunk compaction concurrency configuration', () => {
5
+ test('defaults to two workers', () => {
6
+ expect(normalizeChunkCompactionConcurrency(undefined)).toBe(2);
7
+ });
8
+
9
+ test('defaults to four workers with object storage', () => {
10
+ expect(normalizeChunkCompactionConcurrency(undefined, true)).toBe(4);
11
+ });
12
+
13
+ test.each([false, true])('preserves an explicit override with object storage %s', (hasObjectStorage) => {
14
+ expect(normalizeChunkCompactionConcurrency(3, hasObjectStorage)).toBe(3);
15
+ });
16
+
17
+ test('decodes a configured worker count', () => {
18
+ const config = MongoStorageConfig.decode({
19
+ type: 'mongodb',
20
+ uri: 'mongodb://localhost:27017/powersync',
21
+ chunk_compaction_concurrency: 4
22
+ });
23
+ expect(normalizeChunkCompactionConcurrency(config.chunk_compaction_concurrency)).toBe(4);
24
+ });
25
+
26
+ test.each([1, 64])('accepts boundary worker count %s', (value) => {
27
+ expect(normalizeChunkCompactionConcurrency(value)).toBe(value);
28
+ });
29
+
30
+ test.each([0, -1, 1.5, 65, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])(
31
+ 'rejects invalid worker count %s',
32
+ (value) => {
33
+ expect(() => normalizeChunkCompactionConcurrency(value)).toThrow(
34
+ 'storage.chunk_compaction_concurrency must be an integer between 1 and 64'
35
+ );
36
+ }
37
+ );
38
+ });
@@ -123,14 +123,18 @@ describe('ObjectStorageUsage', () => {
123
123
  await expect(
124
124
  withTransaction(db, async (session) => {
125
125
  await bucketData.insertOne(document, { session });
126
- await usage.applyDelta(definitionId, 123n, session);
126
+ const writes = db.createWriteBatch(session, { ordered: false });
127
+ usage.applyDelta(definitionId, 123n, writes);
128
+ await writes.execute();
127
129
  throw new Error('abort this attempt');
128
130
  })
129
131
  ).rejects.toThrow('abort this attempt');
130
132
 
131
133
  await withTransaction(db, async (session) => {
132
134
  await bucketData.insertOne(document, { session });
133
- await usage.applyDelta(definitionId, 123n, session);
135
+ const writes = db.createWriteBatch(session, { ordered: false });
136
+ usage.applyDelta(definitionId, 123n, writes);
137
+ await writes.execute();
134
138
  });
135
139
 
136
140
  expect(await bucketData.countDocuments({ storage_ref: { $exists: true } })).toBe(1);
@@ -182,7 +186,11 @@ describe('ObjectStorageUsage', () => {
182
186
  const writerId = 'reused-writer';
183
187
  const usage = new ObjectStorageUsage(db, replicationStreamId, writerId);
184
188
 
185
- await withTransaction(db, (session) => usage.applyDelta(definitionId, 10n, session));
189
+ await withTransaction(db, async (session) => {
190
+ const writes = db.createWriteBatch(session, { ordered: false });
191
+ usage.applyDelta(definitionId, 10n, writes);
192
+ await writes.execute();
193
+ });
186
194
  await db.objectStorageUsage.updateOne(
187
195
  { _id: { g: replicationStreamId, w: writerId } },
188
196
  { $set: { updated_at: new Date(0) } }
@@ -191,7 +199,11 @@ describe('ObjectStorageUsage', () => {
191
199
 
192
200
  // The folder committed first and deleted the writer shard. The writer's
193
201
  // next transaction must recreate it through the upsert.
194
- await withTransaction(db, (session) => usage.applyDelta(definitionId, 4n, session));
202
+ await withTransaction(db, async (session) => {
203
+ const writes = db.createWriteBatch(session, { ordered: false });
204
+ usage.applyDelta(definitionId, 4n, writes);
205
+ await writes.execute();
206
+ });
195
207
  expect((await readUsageEntries(db, replicationStreamId))[0].active_bytes).toBe(14n);
196
208
 
197
209
  await db.objectStorageUsage.updateOne(