@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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 (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -0,0 +1,343 @@
1
+ import {
2
+ DeleteObjectsCommand,
3
+ GetObjectCommand,
4
+ ListObjectsV2Command,
5
+ ListObjectsV2CommandOutput,
6
+ PutObjectCommand,
7
+ S3Client
8
+ } from '@aws-sdk/client-s3';
9
+ import { acquireSemaphoreAbortable, isAbortError } from '@powersync/service-core';
10
+ import { isThrottlingError, isTransientError } from '@smithy/core/retry';
11
+ import { Semaphore, SemaphoreInterface } from 'async-mutex';
12
+ import { ObjectStorageError, type ObjectStorage, type ObjectStoragePutMetadata } from './ObjectStorage.js';
13
+
14
+ const DEFAULT_S3_OPERATION_CONCURRENCY = 16;
15
+ const S3_DELETE_PREFIX_BATCH_SIZE = 1000;
16
+ /**
17
+ * Slightly smaller than DEFAULT_S3_OPERATION_CONCURRENCY.
18
+ */
19
+ const S3_DELETE_PREFIX_CONCURRENCY = 12;
20
+ const MAX_S3_PREFIX_BYTES = 256;
21
+ const SAFE_S3_KEY_BYTES = 896;
22
+
23
+ export interface S3ObjectStorageOptions {
24
+ bucket: string;
25
+ region?: string;
26
+ prefix?: string;
27
+ endpoint?: string;
28
+ forcePathStyle?: boolean;
29
+ accessKeyId?: string;
30
+ secretAccessKey?: string;
31
+ concurrencyLimit?: number;
32
+ }
33
+
34
+ export class S3ObjectStorage implements ObjectStorage {
35
+ /**
36
+ * Public for tests only.
37
+ */
38
+ public readonly client: S3Client;
39
+ private bucket: string;
40
+ private prefix: string;
41
+ private readonly operationSemaphore: SemaphoreInterface;
42
+
43
+ constructor(options: S3ObjectStorageOptions) {
44
+ const concurrencyLimit = options.concurrencyLimit ?? DEFAULT_S3_OPERATION_CONCURRENCY;
45
+ if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
46
+ throw new Error('S3 object storage concurrencyLimit must be a positive integer');
47
+ }
48
+ if ((options.accessKeyId == null) !== (options.secretAccessKey == null)) {
49
+ throw new Error('S3 object storage accessKeyId and secretAccessKey must be configured together');
50
+ }
51
+
52
+ const prefix = options.prefix ?? '';
53
+ if (Buffer.byteLength(prefix, 'utf8') > MAX_S3_PREFIX_BYTES) {
54
+ throw new Error(`S3 object storage prefix must be at most ${MAX_S3_PREFIX_BYTES} UTF-8 bytes`);
55
+ }
56
+ if (prefix.endsWith('/')) {
57
+ throw new Error('S3 object storage prefix must not end with "/"');
58
+ }
59
+
60
+ this.bucket = options.bucket;
61
+ this.prefix = prefix;
62
+ this.operationSemaphore = new Semaphore(concurrencyLimit);
63
+ this.client = new S3Client({
64
+ region: options.region,
65
+ endpoint: options.endpoint,
66
+ forcePathStyle: options.forcePathStyle,
67
+ credentials:
68
+ options.accessKeyId && options.secretAccessKey
69
+ ? { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey }
70
+ : undefined
71
+ });
72
+ }
73
+
74
+ async put(path: string, data: Uint8Array, metadata: ObjectStoragePutMetadata): Promise<void> {
75
+ const fullPath = this.fullPath(path);
76
+ await using _ = await this.withOperation();
77
+ try {
78
+ await this.client.send(
79
+ new PutObjectCommand({
80
+ Bucket: this.bucket,
81
+ Key: fullPath,
82
+ Body: data,
83
+ ContentType: metadata.contentType,
84
+ ContentEncoding: metadata.contentEncoding ?? undefined
85
+ })
86
+ );
87
+ } catch (error) {
88
+ throw s3OperationError('upload', fullPath, error);
89
+ }
90
+ }
91
+
92
+ async get(
93
+ path: string,
94
+ options?: { signal?: AbortSignal }
95
+ ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }> {
96
+ const fullPath = this.fullPath(path);
97
+ const signal = options?.signal;
98
+ await using _ = await this.withOperation(signal);
99
+ try {
100
+ const response = await this.client.send(
101
+ new GetObjectCommand({
102
+ Bucket: this.bucket,
103
+ Key: fullPath
104
+ }),
105
+ { abortSignal: signal }
106
+ );
107
+ const contentLength = response.ContentLength;
108
+ if (contentLength == null) {
109
+ throw new Error(`S3 download response for ${fullPath} is missing ContentLength`);
110
+ }
111
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
112
+ throw new Error(`S3 download response for ${fullPath} has invalid ContentLength ${contentLength}`);
113
+ }
114
+
115
+ const data = new Uint8Array(contentLength);
116
+ let offset = 0;
117
+ const stream = response.Body as AsyncIterable<Uint8Array>;
118
+ for await (const chunk of stream) {
119
+ signal?.throwIfAborted();
120
+ const nextOffset = offset + chunk.byteLength;
121
+ if (nextOffset > contentLength) {
122
+ throw new Error(
123
+ `S3 download ContentLength mismatch for ${fullPath}: expected ${contentLength} bytes, received at least ${nextOffset}`
124
+ );
125
+ }
126
+ data.set(chunk, offset);
127
+ offset = nextOffset;
128
+ }
129
+ if (offset !== contentLength) {
130
+ throw new Error(
131
+ `S3 download ContentLength mismatch for ${fullPath}: expected ${contentLength} bytes, received ${offset}`
132
+ );
133
+ }
134
+
135
+ return {
136
+ data,
137
+ metadata: {
138
+ contentType: response.ContentType ?? 'application/octet-stream',
139
+ contentEncoding: response.ContentEncoding ?? null
140
+ }
141
+ };
142
+ } catch (err: any) {
143
+ if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
144
+ throw new ObjectStorageError(`S3 object not found: ${fullPath}`, { cause: err, retryable: false });
145
+ }
146
+ throw s3OperationError('download', fullPath, err);
147
+ }
148
+ }
149
+
150
+ async *list(prefix: string, options?: { signal?: AbortSignal }): AsyncIterable<string> {
151
+ const fullPrefix = this.fullPath(prefix);
152
+ let continuationToken: string | undefined;
153
+ const signal = options?.signal;
154
+
155
+ do {
156
+ signal?.throwIfAborted();
157
+ const response = await this.listPage(fullPrefix, continuationToken, signal);
158
+ for (const object of response.Contents ?? []) {
159
+ if (object.Key == null) {
160
+ continue;
161
+ }
162
+ yield this.prefix ? object.Key.slice(this.prefix.length + 1) : object.Key;
163
+ }
164
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
165
+ if (response.IsTruncated && continuationToken == null) {
166
+ throw new Error(`S3 listing for ${fullPrefix} was truncated without a continuation token`);
167
+ }
168
+ } while (continuationToken != null);
169
+ }
170
+
171
+ async delete(paths: string[]): Promise<void> {
172
+ const fullPaths = paths.map((path) => ({ Key: this.fullPath(path) }));
173
+ await this.deleteFullPaths(fullPaths);
174
+ }
175
+
176
+ async deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }> {
177
+ const fullPrefix = this.fullPath(prefix);
178
+ const signal = options?.signal;
179
+ let continuationToken: string | undefined;
180
+ let objectCount = 0;
181
+ const deleting = new Set<Promise<void>>();
182
+ let deleteError: { error: unknown } | undefined;
183
+
184
+ const scheduleDelete = (fullPaths: { Key: string }[]) => {
185
+ let tracked: Promise<void>;
186
+ tracked = this.deleteFullPaths(fullPaths, signal)
187
+ .catch((error) => {
188
+ deleteError ??= { error };
189
+ })
190
+ .finally(() => deleting.delete(tracked));
191
+ deleting.add(tracked);
192
+ };
193
+ const throwDeleteError = () => {
194
+ if (deleteError != null) {
195
+ throw deleteError.error;
196
+ }
197
+ };
198
+
199
+ let operationError: { error: unknown } | undefined;
200
+ try {
201
+ do {
202
+ signal?.throwIfAborted();
203
+ const response = await this.listPage(fullPrefix, continuationToken, signal);
204
+ throwDeleteError();
205
+ const fullPaths: { Key: string }[] = [];
206
+ for (const object of response.Contents ?? []) {
207
+ if (object.Key != null) {
208
+ fullPaths.push({ Key: object.Key });
209
+ }
210
+ }
211
+ objectCount += fullPaths.length;
212
+ if (fullPaths.length !== 0) {
213
+ while (deleting.size >= S3_DELETE_PREFIX_CONCURRENCY) {
214
+ await Promise.race(deleting);
215
+ throwDeleteError();
216
+ }
217
+ scheduleDelete(fullPaths);
218
+ }
219
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
220
+ if (response.IsTruncated && continuationToken == null) {
221
+ throw new Error(`S3 listing for ${fullPrefix} was truncated without a continuation token`);
222
+ }
223
+ } while (continuationToken != null);
224
+ } catch (error) {
225
+ operationError = { error };
226
+ }
227
+
228
+ await Promise.all(deleting);
229
+ if (operationError != null) {
230
+ throw operationError.error;
231
+ }
232
+ throwDeleteError();
233
+ return { objectCount };
234
+ }
235
+
236
+ private async listPage(
237
+ fullPrefix: string,
238
+ continuationToken: string | undefined,
239
+ signal?: AbortSignal
240
+ ): Promise<ListObjectsV2CommandOutput> {
241
+ try {
242
+ await using _ = await this.withOperation(signal);
243
+ return await this.client.send(
244
+ new ListObjectsV2Command({
245
+ Bucket: this.bucket,
246
+ Prefix: fullPrefix,
247
+ ContinuationToken: continuationToken,
248
+ MaxKeys: S3_DELETE_PREFIX_BATCH_SIZE
249
+ }),
250
+ { abortSignal: signal }
251
+ );
252
+ } catch (error) {
253
+ throw s3OperationError('list', fullPrefix, error);
254
+ }
255
+ }
256
+
257
+ private async deleteFullPaths(fullPaths: { Key: string }[], signal?: AbortSignal): Promise<void> {
258
+ if (fullPaths.length === 0) return;
259
+ await using _ = await this.withOperation(signal);
260
+ let response;
261
+ try {
262
+ response = await this.client.send(
263
+ new DeleteObjectsCommand({
264
+ Bucket: this.bucket,
265
+ Delete: { Objects: fullPaths, Quiet: true }
266
+ }),
267
+ { abortSignal: signal }
268
+ );
269
+ } catch (error) {
270
+ throw s3OperationError('delete', `${fullPaths.length} objects`, error);
271
+ }
272
+ if (response.Errors?.length) {
273
+ const errors = response.Errors.map((error) => `${error.Key}: ${error.Code}`).join(', ');
274
+ throw new ObjectStorageError(`Failed to delete S3 objects: ${errors}`, {
275
+ cause: response.Errors,
276
+ retryable: response.Errors.some((error) => isTransientS3Error({ name: error.Code }))
277
+ });
278
+ }
279
+ }
280
+
281
+ private fullPath(path: string): string {
282
+ const fullPath = this.prefix ? `${this.prefix}/${path}` : path;
283
+ const size = Buffer.byteLength(fullPath, 'utf8');
284
+ if (size > SAFE_S3_KEY_BYTES) {
285
+ throw new Error(`S3 object key is ${size} bytes, exceeding the safe limit of ${SAFE_S3_KEY_BYTES} bytes`);
286
+ }
287
+ return fullPath;
288
+ }
289
+
290
+ private async withOperation(signal?: AbortSignal): Promise<AsyncDisposable> {
291
+ signal?.throwIfAborted();
292
+ const acquired = signal
293
+ ? await acquireSemaphoreAbortable(this.operationSemaphore, signal)
294
+ : await this.operationSemaphore.acquire();
295
+ if (acquired === 'aborted') {
296
+ signal!.throwIfAborted();
297
+ throw new Error('S3 operation aborted while waiting for a concurrency slot');
298
+ }
299
+
300
+ const [, release] = acquired;
301
+ if (signal?.aborted) {
302
+ release();
303
+ signal.throwIfAborted();
304
+ }
305
+ return {
306
+ [Symbol.asyncDispose]: async () => release()
307
+ };
308
+ }
309
+ }
310
+
311
+ function s3OperationError(operation: string, target: string, error: unknown): Error {
312
+ if (error instanceof ObjectStorageError) {
313
+ return error;
314
+ }
315
+ // Preserve abort errors so callers can distinguish cancellation from an S3
316
+ // failure and avoid retrying work after shutdown.
317
+ if (isAbortError(error)) {
318
+ return error as Error;
319
+ }
320
+ const detail = error instanceof Error ? `: ${error.message}` : '';
321
+ return new ObjectStorageError(`S3 ${operation} failed for ${target}${detail}`, {
322
+ cause: error,
323
+ retryable: isTransientS3Error(error)
324
+ });
325
+ }
326
+
327
+ function isTransientS3Error(error: unknown): boolean {
328
+ if (typeof error != 'object' || error == null) {
329
+ return false;
330
+ }
331
+ if (isAbortError(error)) {
332
+ return false;
333
+ }
334
+
335
+ const sdkError = error as Parameters<typeof isTransientError>[0];
336
+ // Use the same classification as the AWS SDK retry middleware. The SDK
337
+ // returns the final error after its own attempt/quota limits are exhausted;
338
+ // this tells the compactor whether restarting the larger bucket operation is
339
+ // still appropriate.
340
+ // One exception is clock skew errors: We don't treat that as retryable by us,
341
+ // although the SDK may internally retry them.
342
+ return isThrottlingError(sdkError) || isTransientError(sdkError);
343
+ }
@@ -1,7 +1,23 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
2
3
  import * as service_types from '@powersync/service-types';
3
4
  import * as t from 'ts-codec';
4
5
 
6
+ const S3ObjectStorageConfig = t.object({
7
+ type: t.literal('s3'),
8
+ bucket: t.string,
9
+ region: t.string.optional(),
10
+ prefix: t.string.optional(),
11
+ endpoint: t.string.optional(),
12
+ force_path_style: t.boolean.optional(),
13
+ access_key_id: t.string.optional(),
14
+ secret_access_key: t.string.optional(),
15
+ concurrency_limit: t.number.optional(),
16
+ // Chunks whose BSON-serialized size falls below this byte threshold
17
+ // stay inline in MongoDB instead of being offloaded to S3. Default 1024.
18
+ inline_threshold_bytes: t.number.optional()
19
+ });
20
+
5
21
  export const MongoStorageReadPreference = t
6
22
  .literal('primary')
7
23
  .or(t.literal('primaryPreferred'))
@@ -20,13 +36,38 @@ export const MongoStorageConfig = lib_mongo.BaseMongoConfig.and(
20
36
  *
21
37
  * This is an experimental option, and may be removed in a future release.
22
38
  */
23
- bulk_read_preference: MongoStorageReadPreference.optional()
39
+ bulk_read_preference: MongoStorageReadPreference.optional(),
40
+ /**
41
+ * Throttle clear operations after sync config deploys, by pausing between batches.
42
+ * The rate produces a pause proportional to the previous batch duration.
43
+ *
44
+ * Increase this to reduce impact of clear operations on the storage cluster.
45
+ * Use 0 to clear as fast as possible.
46
+ *
47
+ * Defaults to 0.2.
48
+ */
49
+ clear_batch_throttle_rate: t.number.optional(),
50
+
51
+ object_storage: S3ObjectStorageConfig.optional()
24
52
  })
25
53
  );
26
54
 
27
55
  export type MongoStorageConfig = t.Encoded<typeof MongoStorageConfig>;
28
56
  export type MongoStorageConfigDecoded = t.Decoded<typeof MongoStorageConfig>;
29
57
 
58
+ export const DEFAULT_CLEAR_BATCH_THROTTLE_RATE = 0.2;
59
+
60
+ export function normalizeClearBatchThrottleRate(value: number | undefined): number {
61
+ const rate = value ?? DEFAULT_CLEAR_BATCH_THROTTLE_RATE;
62
+ if (!Number.isFinite(rate) || rate < 0 || rate > 20) {
63
+ throw new ServiceError(
64
+ ErrorCode.PSYNC_S3201,
65
+ 'storage.clear_batch_throttle_rate must be a finite number between 0 and 20'
66
+ );
67
+ }
68
+ return rate;
69
+ }
70
+
30
71
  export function isMongoStorageConfig(
31
72
  config: service_types.configFile.GenericStorageConfig
32
73
  ): config is MongoStorageConfig {
@@ -29,7 +29,9 @@ export function mongoTestStorageFactoryGenerator(factoryOptions: MongoTestStorag
29
29
  return new MongoBucketStorage(db, {
30
30
  replicationStreamNamePrefix: 'test_',
31
31
  checksumOptions: factoryOptions.checksumOptions,
32
- supportsMultipleSyncConfigs: factoryOptions.supportsMultipleSyncConfigs
32
+ supportsMultipleSyncConfigs: factoryOptions.supportsMultipleSyncConfigs,
33
+ objectStorage: factoryOptions.objectStorage,
34
+ inlineThresholdBytes: factoryOptions.inlineThresholdBytes
33
35
  });
34
36
  },
35
37
  tableIdStrings: false
package/src/utils/util.ts CHANGED
@@ -1,14 +1,56 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { mongo } from '@powersync/lib-service-mongodb';
3
- import { ReplicationAbortedError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { Logger, ReplicationAbortedError, ServiceAssertionError } from '@powersync/lib-services-framework';
4
4
  import { storage, utils } from '@powersync/service-core';
5
5
  import * as bson from 'bson';
6
6
  import * as crypto from 'crypto';
7
7
  import * as timers from 'node:timers/promises';
8
8
  import * as uuid from 'uuid';
9
9
  import { BucketDataDoc } from '../storage/implementation/common/BucketDataDoc.js';
10
+ import { DEFAULT_CLEAR_BATCH_THROTTLE_RATE } from '../types/types.js';
10
11
 
11
- export function idPrefixFilter<T>(prefix: Partial<T>, rest: (keyof T)[]): mongo.Condition<T> {
12
+ /**
13
+ * Default and max batch size for clearing.
14
+ */
15
+ const CLEAR_BATCH_SIZE = 10_000;
16
+
17
+ /**
18
+ * Minimum batch size for clearing when a batch takes too long.
19
+ */
20
+ const CLEAR_MIN_BATCH_SIZE = 100;
21
+
22
+ /**
23
+ * We target batch duration to stay between the min and max. If the druation is outside this range,
24
+ * we increase or decrease the batch size.
25
+ *
26
+ * MONGO_CLEAR_OPERATION_TIMEOUT_MS controls the absolute max of individual find and delete operations,
27
+ * but we try to avoid hitting that.
28
+ */
29
+ const CLEAR_BATCH_MIN_DURATION_MS = 500;
30
+ const CLEAR_BATCH_MAX_DURATION_MS = 2000;
31
+
32
+ type ClearCollectionFilter<T extends mongo.Document> = mongo.Filter<T> & {
33
+ _id: mongo.FilterOperators<mongo.InferIdType<T>>;
34
+ };
35
+
36
+ function throwIfClearAborted(signal?: AbortSignal): void {
37
+ if (signal?.aborted) {
38
+ throw new ReplicationAbortedError('Aborted clearing data', signal.reason);
39
+ }
40
+ }
41
+
42
+ async function waitWithSignal(delayMs: number, signal: AbortSignal | undefined, abortMessage: string): Promise<void> {
43
+ try {
44
+ await timers.setTimeout(delayMs, undefined, { signal });
45
+ } catch (error) {
46
+ if (signal?.aborted) {
47
+ throw new ReplicationAbortedError(abortMessage, signal.reason);
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ export function idPrefixFilter<T>(prefix: Partial<T>, rest: (keyof T)[]): mongo.FilterOperators<T> {
12
54
  let filter = {
13
55
  $gte: {
14
56
  ...prefix
@@ -149,16 +191,152 @@ export async function retryOnMongoMaxTimeMSExpired<T>(
149
191
  try {
150
192
  return await operation();
151
193
  } catch (e) {
152
- if (!lib_mongo.isMongoServerError(e) || e.codeName !== 'MaxTimeMSExpired') {
194
+ if (!lib_mongo.isMaxTimeMSExpiredError(e)) {
153
195
  throw e;
154
196
  }
155
197
  retryCount += 1;
156
198
  options.onRetry?.(retryCount);
157
- await timers.setTimeout(options.retryDelayMs);
199
+ await waitWithSignal(options.retryDelayMs, options.signal, options.abortMessage ?? 'Aborted MongoDB operation');
200
+ }
201
+ }
202
+ }
203
+
204
+ async function clearCollectionInBatches(
205
+ logger: Logger,
206
+ label: string,
207
+ findAndDeleteBatch: (batchSize: number) => Promise<number>,
208
+ signal?: AbortSignal,
209
+ throttleRate = DEFAULT_CLEAR_BATCH_THROTTLE_RATE
210
+ ): Promise<number> {
211
+ let batchSize = CLEAR_BATCH_SIZE;
212
+ let deletedCount = 0;
213
+ while (true) {
214
+ throwIfClearAborted(signal);
215
+ try {
216
+ const batchStartedAt = performance.now();
217
+ const foundBatchSize = await findAndDeleteBatch(batchSize);
218
+ const batchDurationMs = performance.now() - batchStartedAt;
219
+ deletedCount += foundBatchSize;
220
+ if (foundBatchSize > 0) {
221
+ logger.info(
222
+ `Cleared batch of ${label} (${foundBatchSize} documents) in ${Math.round(batchDurationMs)}ms, continuing...`
223
+ );
224
+ }
225
+ if (foundBatchSize < batchSize) {
226
+ return deletedCount;
227
+ }
228
+ if (batchDurationMs > CLEAR_BATCH_MAX_DURATION_MS) {
229
+ batchSize = Math.max(CLEAR_MIN_BATCH_SIZE, Math.floor(batchSize / 2));
230
+ } else if (batchDurationMs < CLEAR_BATCH_MIN_DURATION_MS) {
231
+ batchSize = Math.min(CLEAR_BATCH_SIZE, batchSize * 2);
232
+ }
233
+ await waitWithSignal(batchDurationMs * throttleRate, signal, 'Aborted clearing data');
234
+ } catch (error) {
235
+ if (!lib_mongo.isTimeoutError(error) || batchSize == CLEAR_MIN_BATCH_SIZE) {
236
+ throw error;
237
+ }
238
+ const nextBatchSize = Math.max(CLEAR_MIN_BATCH_SIZE, Math.floor(batchSize / 2));
239
+ logger.info(
240
+ `Clearing batch of ${label} timed out with batch size ${batchSize}, retrying find and delete with ${nextBatchSize}...`
241
+ );
242
+ batchSize = nextBatchSize;
243
+
244
+ // Pause for at least the equivalent of one query timeout before retrying, since the cause may be high database load.
245
+ const pauseDuration = Math.max(throttleRate, 1.0) * lib_mongo.MONGO_CLEAR_OPERATION_TIMEOUT_MS;
246
+ await waitWithSignal(pauseDuration, signal, 'Aborted clearing data');
158
247
  }
159
248
  }
160
249
  }
161
250
 
251
+ export async function clearCollectionInIdRanges<T extends mongo.Document>(
252
+ logger: Logger,
253
+ label: string,
254
+ collection: mongo.Collection<T>,
255
+ filter: ClearCollectionFilter<T>,
256
+ signal?: AbortSignal,
257
+ throttleRate = DEFAULT_CLEAR_BATCH_THROTTLE_RATE
258
+ ): Promise<number> {
259
+ return clearCollectionInBatches(
260
+ logger,
261
+ label,
262
+ async (batchSize) => {
263
+ const queryOptions: mongo.FindOptions<T> = {
264
+ maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS,
265
+ projection: { _id: 1 }
266
+ };
267
+ const [batchEnd] = await collection
268
+ .find(filter, queryOptions)
269
+ .sort({ _id: 1 })
270
+ .skip(batchSize)
271
+ .limit(1)
272
+ .toArray();
273
+ throwIfClearAborted(signal);
274
+
275
+ if (batchEnd == null) {
276
+ // We're on the last batch
277
+ const result = await collection.deleteMany(filter, {
278
+ maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS
279
+ });
280
+ return result.deletedCount;
281
+ }
282
+
283
+ const idRange: mongo.FilterOperators<mongo.InferIdType<T>> = {
284
+ ...filter._id,
285
+ $lt: batchEnd._id
286
+ };
287
+ await collection.deleteMany(
288
+ {
289
+ ...filter,
290
+ _id: idRange
291
+ } as mongo.Filter<T>,
292
+ { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
293
+ );
294
+ return batchSize;
295
+ },
296
+ signal,
297
+ throttleRate
298
+ );
299
+ }
300
+
301
+ export async function clearCollectionInIdBatches<T extends mongo.Document>(
302
+ logger: Logger,
303
+ label: string,
304
+ collection: mongo.Collection<T>,
305
+ filter: mongo.Filter<T>,
306
+ signal?: AbortSignal,
307
+ throttleRate = DEFAULT_CLEAR_BATCH_THROTTLE_RATE
308
+ ): Promise<number> {
309
+ return clearCollectionInBatches(
310
+ logger,
311
+ label,
312
+ async (batchSize) => {
313
+ const documents = await collection
314
+ .find(filter, {
315
+ maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS,
316
+ projection: { _id: 1 }
317
+ })
318
+ .limit(batchSize)
319
+ .toArray();
320
+ throwIfClearAborted(signal);
321
+
322
+ if (documents.length === 0) {
323
+ return 0;
324
+ }
325
+ await collection.deleteMany(
326
+ {
327
+ _id: {
328
+ $in: documents.map((document) => document._id)
329
+ }
330
+ } as mongo.Filter<T>,
331
+ { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
332
+ );
333
+ return documents.length;
334
+ },
335
+ signal,
336
+ throttleRate
337
+ );
338
+ }
339
+
162
340
  export const createPaginatedConnectionQuery = async <T extends mongo.Document>(
163
341
  query: mongo.Filter<T>,
164
342
  collection: mongo.Collection<T>,