@powersync/service-module-mongodb-storage 0.20.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +3 -1
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +31 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +22 -0
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +90 -19
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +84 -4
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  106. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +34 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +31 -0
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +124 -22
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +91 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +336 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,230 @@
1
+ import * as lib_mongo from '@powersync/lib-service-mongodb';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
4
+ import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { BucketDataDocumentV3, ObjectStorageUsageDocument } from '../models.js';
7
+ import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
8
+
9
+ export const OBJECT_STORAGE_USAGE_BASE_WRITER_ID = '__base__';
10
+ export const DEFAULT_OBJECT_STORAGE_USAGE_STALE_WRITER_MS = 30 * 60 * 1000;
11
+ export const DEFAULT_OBJECT_STORAGE_USAGE_FOLD_LIMIT = 100;
12
+
13
+ export interface ReplicationStreamObjectStorageDefinitionUsageResult {
14
+ replication_stream_id: number;
15
+ definition_id: BucketDefinitionId;
16
+ active_bytes: bigint;
17
+ }
18
+
19
+ export function createObjectStorageUsageWriterId(): string {
20
+ return randomUUID();
21
+ }
22
+
23
+ /**
24
+ * Tracks active S3 references. Values are signed writer-local deltas, so reference changes and
25
+ * accounting updates can be committed together without making replication and compaction share a
26
+ * hot counter document.
27
+ */
28
+ export class ObjectStorageUsage {
29
+ constructor(
30
+ private readonly db: VersionedPowerSyncMongoV3,
31
+ private readonly replicationStreamId: number,
32
+ readonly writerId?: string
33
+ ) {
34
+ if (writerId === OBJECT_STORAGE_USAGE_BASE_WRITER_ID) {
35
+ throw new ReplicationAssertionError('The reserved object-storage usage writer id cannot be used by a writer');
36
+ }
37
+ }
38
+
39
+ static bytes(document: Pick<BucketDataDocumentV3, 'storage_ref'>): bigint {
40
+ return BigInt(document.storage_ref?.file_size ?? 0);
41
+ }
42
+
43
+ static async readAllDefinitionUsage(
44
+ db: VersionedPowerSyncMongoV3
45
+ ): Promise<ReplicationStreamObjectStorageDefinitionUsageResult[]> {
46
+ return db.client.withSession({ snapshot: true }, async (session) => {
47
+ // Deliberately read the whole usage collection. The number of replication streams is
48
+ // expected to stay low, so a collection scan is cheaper than issuing one _id range query
49
+ // per stream (and the usage collection is bounded by streams and writers, not buckets).
50
+ const entries = await db.objectStorageUsage
51
+ .aggregate<{
52
+ _id: { replication_stream_id: number; definition_id: BucketDefinitionId };
53
+ active_bytes: bigint;
54
+ }>(
55
+ [
56
+ { $project: { replication_stream_id: '$_id.g', definitions: { $objectToArray: '$definitions' } } },
57
+ { $unwind: '$definitions' },
58
+ {
59
+ $group: {
60
+ _id: {
61
+ replication_stream_id: '$replication_stream_id',
62
+ definition_id: '$definitions.k'
63
+ },
64
+ active_bytes: { $sum: '$definitions.v' }
65
+ }
66
+ }
67
+ ],
68
+ { session, readConcern: 'snapshot' }
69
+ )
70
+ .toArray()
71
+ .catch((error) => {
72
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
73
+ return [];
74
+ }
75
+ throw error;
76
+ });
77
+
78
+ return entries.map((entry) => {
79
+ const activeBytes = BigInt(entry.active_bytes ?? 0);
80
+ if (activeBytes < 0n) {
81
+ return {
82
+ replication_stream_id: entry._id.replication_stream_id,
83
+ definition_id: entry._id.definition_id,
84
+ active_bytes: 0n
85
+ };
86
+ }
87
+ return {
88
+ replication_stream_id: entry._id.replication_stream_id,
89
+ definition_id: entry._id.definition_id,
90
+ active_bytes: activeBytes
91
+ };
92
+ });
93
+ });
94
+ }
95
+
96
+ async applyDelta(definitionId: BucketDefinitionId, delta: bigint, session: mongo.ClientSession): Promise<void> {
97
+ await this.applyDeltas(new Map([[definitionId, delta]]), session);
98
+ }
99
+
100
+ async applyDeltas(deltas: ReadonlyMap<BucketDefinitionId, bigint>, session: mongo.ClientSession): Promise<void> {
101
+ if (this.writerId == null) {
102
+ throw new ReplicationAssertionError('A writer id is required to apply object-storage usage');
103
+ }
104
+
105
+ const increments: Record<string, bigint> = {};
106
+ for (const [definitionId, delta] of deltas) {
107
+ if (delta === 0n) {
108
+ continue;
109
+ }
110
+ increments[this.definitionPath(definitionId)] = delta;
111
+ }
112
+ if (Object.keys(increments).length === 0) {
113
+ return;
114
+ }
115
+
116
+ await this.db.objectStorageUsage.updateOne(
117
+ { _id: this.documentId() },
118
+ {
119
+ $inc: increments,
120
+ $currentDate: { updated_at: true }
121
+ },
122
+ { upsert: true, session }
123
+ );
124
+ }
125
+
126
+ async removeDefinition(definitionId: BucketDefinitionId, session: mongo.ClientSession): Promise<void> {
127
+ await this.db.objectStorageUsage.updateMany(
128
+ { '_id.g': this.replicationStreamId },
129
+ { $unset: { [this.definitionPath(definitionId)]: 1 } },
130
+ { session }
131
+ );
132
+ }
133
+
134
+ async removeStream(session: mongo.ClientSession): Promise<void> {
135
+ await this.db.objectStorageUsage.deleteMany({ '_id.g': this.replicationStreamId }, { session });
136
+ }
137
+
138
+ async foldStaleWriterDeltas(
139
+ options: {
140
+ staleWriterMs?: number;
141
+ limit?: number;
142
+ } = {}
143
+ ): Promise<void> {
144
+ const staleWriterMs = options.staleWriterMs ?? DEFAULT_OBJECT_STORAGE_USAGE_STALE_WRITER_MS;
145
+ const limit = options.limit ?? DEFAULT_OBJECT_STORAGE_USAGE_FOLD_LIMIT;
146
+ if (limit <= 0) {
147
+ return;
148
+ }
149
+
150
+ await this.db.client.withSession((session) =>
151
+ session.withTransaction(
152
+ async () => {
153
+ const staleDocuments = await this.db.objectStorageUsage
154
+ .find(
155
+ {
156
+ '_id.g': this.replicationStreamId,
157
+ '_id.w': { $ne: OBJECT_STORAGE_USAGE_BASE_WRITER_ID },
158
+ $expr: {
159
+ $lt: [
160
+ '$updated_at',
161
+ { $dateSubtract: { startDate: '$$NOW', unit: 'millisecond', amount: staleWriterMs } }
162
+ ]
163
+ }
164
+ },
165
+ { session, sort: { updated_at: 1 }, limit }
166
+ )
167
+ .toArray();
168
+ if (staleDocuments.length === 0) {
169
+ return;
170
+ }
171
+
172
+ const deltas = new Map<BucketDefinitionId, bigint>();
173
+ for (const document of staleDocuments) {
174
+ for (const [definitionId, delta] of Object.entries(document.definitions ?? {})) {
175
+ this.validateDefinitionId(definitionId);
176
+ deltas.set(definitionId, (deltas.get(definitionId) ?? 0n) + BigInt(delta));
177
+ }
178
+ }
179
+
180
+ const increments: Record<string, bigint> = {};
181
+ for (const [definitionId, delta] of deltas) {
182
+ if (delta !== 0n) {
183
+ increments[this.definitionPath(definitionId)] = delta;
184
+ }
185
+ }
186
+ if (Object.keys(increments).length > 0) {
187
+ await this.db.objectStorageUsage.updateOne(
188
+ { _id: this.documentId(OBJECT_STORAGE_USAGE_BASE_WRITER_ID) },
189
+ {
190
+ $inc: increments,
191
+ $currentDate: { updated_at: true }
192
+ },
193
+ { upsert: true, session }
194
+ );
195
+ }
196
+
197
+ await this.db.objectStorageUsage.deleteMany(
198
+ {
199
+ '_id.g': this.replicationStreamId,
200
+ _id: { $in: staleDocuments.map((document) => document._id) }
201
+ },
202
+ { session }
203
+ );
204
+ },
205
+ { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } }
206
+ )
207
+ );
208
+ }
209
+
210
+ private definitionPath(definitionId: BucketDefinitionId): string {
211
+ this.validateDefinitionId(definitionId);
212
+ return `definitions.${definitionId}`;
213
+ }
214
+
215
+ private documentId(writerId = this.writerId): ObjectStorageUsageDocument['_id'] {
216
+ if (writerId == null) {
217
+ throw new ReplicationAssertionError('A writer id is required to identify object-storage usage');
218
+ }
219
+ return {
220
+ g: this.replicationStreamId,
221
+ w: writerId
222
+ };
223
+ }
224
+
225
+ private validateDefinitionId(definitionId: BucketDefinitionId): void {
226
+ if (definitionId.length === 0 || definitionId.includes('.') || definitionId.includes('$')) {
227
+ throw new ReplicationAssertionError(`Invalid bucket definition id for object-storage usage: ${definitionId}`);
228
+ }
229
+ }
230
+ }
@@ -6,10 +6,18 @@ import {
6
6
  PutObjectCommand,
7
7
  S3Client
8
8
  } from '@aws-sdk/client-s3';
9
+ import { logger } from '@powersync/lib-services-framework';
9
10
  import { acquireSemaphoreAbortable, isAbortError } from '@powersync/service-core';
11
+ import { loadConfigsForDefaultMode, type DefaultsMode, type ResolvedDefaultsMode } from '@smithy/core/client';
10
12
  import { isThrottlingError, isTransientError } from '@smithy/core/retry';
13
+ import { NodeHttpHandler } from '@smithy/node-http-handler';
11
14
  import { Semaphore, SemaphoreInterface } from 'async-mutex';
12
- import { ObjectStorageError, type ObjectStorage, type ObjectStoragePutMetadata } from './ObjectStorage.js';
15
+ import {
16
+ ObjectStorageError,
17
+ type ObjectStorage,
18
+ type ObjectStorageOperationOptions,
19
+ type ObjectStoragePutMetadata
20
+ } from './ObjectStorage.js';
13
21
 
14
22
  const DEFAULT_S3_OPERATION_CONCURRENCY = 16;
15
23
  const S3_DELETE_PREFIX_BATCH_SIZE = 1000;
@@ -20,6 +28,141 @@ const S3_DELETE_PREFIX_CONCURRENCY = 12;
20
28
  const MAX_S3_PREFIX_BYTES = 256;
21
29
  const SAFE_S3_KEY_BYTES = 896;
22
30
 
31
+ /**
32
+ * Timeouts are derived from the AWS defaults mode, rather than being individually configurable.
33
+ *
34
+ * The mode - AWS_DEFAULTS_MODE or the defaults_mode storage option - describes the expected latency
35
+ * between this service and the object storage endpoint. We use the connection timeout it defines
36
+ * as the baseline for the other timeouts:
37
+ *
38
+ * | mode | connection | request | operation | queue |
39
+ * | ---------------------- | ---------- | ------- | --------- | ----- |
40
+ * | in-region | 1.1s | 5.5s | 11s | 44s |
41
+ * | standard, cross-region | 3.1s | 15.5s | 31s | 124s |
42
+ * | mobile | 30s | 150s | 300s | 1200s |
43
+ *
44
+ * The multipliers are sized for the objects we store: bucket data chunks target 1MB, and are
45
+ * bounded by the 16MB BSON document limit. Even at the upper bound, the request timeout only has to
46
+ * cover a transfer of a few MB/s, and the operation timeout leaves room for a second attempt.
47
+ *
48
+ * `legacy`, the mode used when nothing is configured, does not define any timeouts, and is treated
49
+ * as `standard` here. Running without timeouts is not an option: a stalled request holds one of the
50
+ * limited operation slots until the process restarts, and enough of those block all reads.
51
+ *
52
+ * `auto` is also treated as `standard` - see resolveConfiguredDefaultsMode() below.
53
+ */
54
+ const BASELINE_CONNECTION_TIMEOUT_MS = 3_100;
55
+ const REQUEST_TIMEOUT_FACTOR = 5;
56
+ const OPERATION_TIMEOUT_FACTOR = 2;
57
+ /**
58
+ * How long an operation may wait for a concurrency slot, as a multiple of the operation timeout.
59
+ *
60
+ * Every slot holder now releases within the operation timeout, so the queue always drains: this is
61
+ * 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
63
+ * default concurrency. Real operations complete in a fraction of the deadline, so the number of
64
+ * queued operations this actually tolerates is far higher, well above the ~2000 uploads that the
65
+ * largest possible replication flush (MAX_TRANSACTION_DOC_COUNT) can enqueue at once.
66
+ */
67
+ const QUEUE_TIMEOUT_FACTOR = 4;
68
+
69
+ export interface S3TimeoutProfile {
70
+ /**
71
+ * Per attempt: establishing the connection, including waiting for a socket from the pool.
72
+ */
73
+ connectionTimeoutMs: number;
74
+ /**
75
+ * Per attempt: from starting the request until the response headers are received. For uploads
76
+ * this includes sending the body.
77
+ */
78
+ requestTimeoutMs: number;
79
+ /**
80
+ * The complete operation, including SDK retries and streaming the response body.
81
+ *
82
+ * The SDK timeouts above stop applying once the response headers are received, so this is what
83
+ * bounds how long a single operation can occupy a concurrency slot.
84
+ */
85
+ operationTimeoutMs: number;
86
+ /**
87
+ * Waiting for a concurrency slot, before the operation itself starts.
88
+ */
89
+ queueTimeoutMs: number;
90
+ }
91
+
92
+ export function resolveTimeoutProfile(mode: ResolvedDefaultsMode): S3TimeoutProfile {
93
+ // Only connectionTimeout is currently vended by the SDK, but requestTimeout is part of the same
94
+ // contract - use it as the hint if a future version starts defining it.
95
+ const { connectionTimeout, requestTimeout } = loadConfigsForDefaultMode(mode);
96
+ const connectionTimeoutMs = connectionTimeout ?? BASELINE_CONNECTION_TIMEOUT_MS;
97
+ const requestTimeoutMs = requestTimeout ?? connectionTimeoutMs * REQUEST_TIMEOUT_FACTOR;
98
+ const operationTimeoutMs = requestTimeoutMs * OPERATION_TIMEOUT_FACTOR;
99
+ return {
100
+ connectionTimeoutMs,
101
+ requestTimeoutMs,
102
+ operationTimeoutMs,
103
+ queueTimeoutMs: operationTimeoutMs * QUEUE_TIMEOUT_FACTOR
104
+ };
105
+ }
106
+
107
+ const AWS_DEFAULTS_MODE_ENV = 'AWS_DEFAULTS_MODE';
108
+ const RESOLVED_DEFAULTS_MODES: ResolvedDefaultsMode[] = ['standard', 'in-region', 'cross-region', 'mobile', 'legacy'];
109
+
110
+ /**
111
+ * Resolve the configured AWS defaults mode: the explicit option, then the AWS_DEFAULTS_MODE
112
+ * environment variable, then `legacy`, which is also the AWS default.
113
+ *
114
+ * Unlike the SDK's own lookup, this does not read defaults_mode from the AWS shared config file.
115
+ * That lookup is asynchronous, and the mode is only used to pick timeouts, which we want available
116
+ * synchronously so that operations never have to wait on configuration.
117
+ *
118
+ * `auto` is substituted with `standard`. It asks the SDK to detect whether it is running in the
119
+ * same region as the endpoint, which means querying the EC2 instance metadata service on startup:
120
+ *
121
+ * - The detection only succeeds on EC2-family compute with instance metadata reachable. Anywhere
122
+ * else - including EC2 with IMDS disabled or behind a low hop limit - it silently falls back to
123
+ * `standard`, so the timeouts differ by deployment with no indication of why.
124
+ * - It requires a resolvable region, and fails the client outright when there is none.
125
+ * - Timeouts that depend on where the process happens to run are hard to reason about when
126
+ * diagnosing a stall.
127
+ *
128
+ * Deployments that want the tighter in-region timeouts should ask for them explicitly.
129
+ */
130
+ function resolveConfiguredDefaultsMode(configured?: DefaultsMode): ResolvedDefaultsMode {
131
+ // An empty environment variable is treated as unset, matching the SDK.
132
+ const mode = (configured || process.env[AWS_DEFAULTS_MODE_ENV] || 'legacy').toLowerCase();
133
+ if (mode === 'auto') {
134
+ logger.warn(
135
+ `Ignoring AWS defaults mode "auto" for object storage, using "standard" instead. ` +
136
+ `Configure storage.object_storage.defaults_mode to select a mode explicitly.`
137
+ );
138
+ return 'standard';
139
+ }
140
+ const resolved = RESOLVED_DEFAULTS_MODES.find((candidate) => candidate === mode);
141
+ if (resolved == null) {
142
+ throw new Error(
143
+ `Invalid AWS defaults mode ${JSON.stringify(mode)}, expected one of ${RESOLVED_DEFAULTS_MODES.join(', ')}, auto`
144
+ );
145
+ }
146
+ return resolved;
147
+ }
148
+
149
+ /**
150
+ * An S3 operation holding a concurrency slot, released when the operation is disposed.
151
+ */
152
+ interface S3Operation extends AsyncDisposable {
153
+ /**
154
+ * The caller's signal, combined with the operation deadline.
155
+ *
156
+ * Use this for the request itself, so that neither a stalled request nor a stalled response body
157
+ * can hold on to the slot indefinitely.
158
+ */
159
+ signal: AbortSignal;
160
+ /**
161
+ * The error to report for a failed operation.
162
+ */
163
+ error(cause: unknown): Error;
164
+ }
165
+
23
166
  export interface S3ObjectStorageOptions {
24
167
  bucket: string;
25
168
  region?: string;
@@ -29,6 +172,12 @@ export interface S3ObjectStorageOptions {
29
172
  accessKeyId?: string;
30
173
  secretAccessKey?: string;
31
174
  concurrencyLimit?: number;
175
+ /**
176
+ * AWS defaults mode, used as the baseline for the request timeouts.
177
+ *
178
+ * Defaults to the AWS_DEFAULTS_MODE environment variable. `auto` is treated as `standard`.
179
+ */
180
+ defaultsMode?: DefaultsMode;
32
181
  }
33
182
 
34
183
  export class S3ObjectStorage implements ObjectStorage {
@@ -39,6 +188,7 @@ export class S3ObjectStorage implements ObjectStorage {
39
188
  private bucket: string;
40
189
  private prefix: string;
41
190
  private readonly operationSemaphore: SemaphoreInterface;
191
+ private readonly timeouts: S3TimeoutProfile;
42
192
 
43
193
  constructor(options: S3ObjectStorageOptions) {
44
194
  const concurrencyLimit = options.concurrencyLimit ?? DEFAULT_S3_OPERATION_CONCURRENCY;
@@ -60,10 +210,22 @@ export class S3ObjectStorage implements ObjectStorage {
60
210
  this.bucket = options.bucket;
61
211
  this.prefix = prefix;
62
212
  this.operationSemaphore = new Semaphore(concurrencyLimit);
213
+
214
+ // Resolve the mode once, and share it with the client so that its own defaults agree with ours.
215
+ const defaultsMode = resolveConfiguredDefaultsMode(options.defaultsMode);
216
+ this.timeouts = resolveTimeoutProfile(defaultsMode);
217
+
63
218
  this.client = new S3Client({
64
219
  region: options.region,
65
220
  endpoint: options.endpoint,
66
221
  forcePathStyle: options.forcePathStyle,
222
+ defaultsMode,
223
+ requestHandler: new NodeHttpHandler({
224
+ connectionTimeout: this.timeouts.connectionTimeoutMs,
225
+ requestTimeout: this.timeouts.requestTimeoutMs,
226
+ // Without this, exceeding requestTimeout only logs a warning.
227
+ throwOnRequestTimeout: true
228
+ }),
67
229
  credentials:
68
230
  options.accessKeyId && options.secretAccessKey
69
231
  ? { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey }
@@ -71,9 +233,14 @@ export class S3ObjectStorage implements ObjectStorage {
71
233
  });
72
234
  }
73
235
 
74
- async put(path: string, data: Uint8Array, metadata: ObjectStoragePutMetadata): Promise<void> {
236
+ async put(
237
+ path: string,
238
+ data: Uint8Array,
239
+ metadata: ObjectStoragePutMetadata,
240
+ options?: ObjectStorageOperationOptions
241
+ ): Promise<void> {
75
242
  const fullPath = this.fullPath(path);
76
- await using _ = await this.withOperation();
243
+ await using operation = await this.withOperation('upload', fullPath, options?.signal);
77
244
  try {
78
245
  await this.client.send(
79
246
  new PutObjectCommand({
@@ -82,20 +249,22 @@ export class S3ObjectStorage implements ObjectStorage {
82
249
  Body: data,
83
250
  ContentType: metadata.contentType,
84
251
  ContentEncoding: metadata.contentEncoding ?? undefined
85
- })
252
+ }),
253
+ { abortSignal: operation.signal }
86
254
  );
87
255
  } catch (error) {
88
- throw s3OperationError('upload', fullPath, error);
256
+ throw operation.error(error);
89
257
  }
90
258
  }
91
259
 
92
260
  async get(
93
261
  path: string,
94
- options?: { signal?: AbortSignal }
262
+ options?: ObjectStorageOperationOptions
95
263
  ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }> {
96
264
  const fullPath = this.fullPath(path);
97
- const signal = options?.signal;
98
- await using _ = await this.withOperation(signal);
265
+ await using operation = await this.withOperation('download', fullPath, options?.signal);
266
+ // Includes the operation deadline, which also covers streaming the response body below.
267
+ const signal = operation.signal;
99
268
  try {
100
269
  const response = await this.client.send(
101
270
  new GetObjectCommand({
@@ -116,7 +285,7 @@ export class S3ObjectStorage implements ObjectStorage {
116
285
  let offset = 0;
117
286
  const stream = response.Body as AsyncIterable<Uint8Array>;
118
287
  for await (const chunk of stream) {
119
- signal?.throwIfAborted();
288
+ signal.throwIfAborted();
120
289
  const nextOffset = offset + chunk.byteLength;
121
290
  if (nextOffset > contentLength) {
122
291
  throw new Error(
@@ -143,11 +312,11 @@ export class S3ObjectStorage implements ObjectStorage {
143
312
  if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
144
313
  throw new ObjectStorageError(`S3 object not found: ${fullPath}`, { cause: err, retryable: false });
145
314
  }
146
- throw s3OperationError('download', fullPath, err);
315
+ throw operation.error(err);
147
316
  }
148
317
  }
149
318
 
150
- async *list(prefix: string, options?: { signal?: AbortSignal }): AsyncIterable<string> {
319
+ async *list(prefix: string, options?: ObjectStorageOperationOptions): AsyncIterable<string> {
151
320
  const fullPrefix = this.fullPath(prefix);
152
321
  let continuationToken: string | undefined;
153
322
  const signal = options?.signal;
@@ -168,12 +337,12 @@ export class S3ObjectStorage implements ObjectStorage {
168
337
  } while (continuationToken != null);
169
338
  }
170
339
 
171
- async delete(paths: string[]): Promise<void> {
340
+ async delete(paths: string[], options?: ObjectStorageOperationOptions): Promise<void> {
172
341
  const fullPaths = paths.map((path) => ({ Key: this.fullPath(path) }));
173
- await this.deleteFullPaths(fullPaths);
342
+ await this.deleteFullPaths(fullPaths, options?.signal);
174
343
  }
175
344
 
176
- async deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }> {
345
+ async deletePrefix(prefix: string, options?: ObjectStorageOperationOptions): Promise<{ objectCount: number }> {
177
346
  const fullPrefix = this.fullPath(prefix);
178
347
  const signal = options?.signal;
179
348
  let continuationToken: string | undefined;
@@ -238,8 +407,10 @@ export class S3ObjectStorage implements ObjectStorage {
238
407
  continuationToken: string | undefined,
239
408
  signal?: AbortSignal
240
409
  ): Promise<ListObjectsV2CommandOutput> {
410
+ // Acquired outside the try, matching the other operations: withOperation() already reports its
411
+ // own failures as ObjectStorageError.
412
+ await using operation = await this.withOperation('list', fullPrefix, signal);
241
413
  try {
242
- await using _ = await this.withOperation(signal);
243
414
  return await this.client.send(
244
415
  new ListObjectsV2Command({
245
416
  Bucket: this.bucket,
@@ -247,16 +418,16 @@ export class S3ObjectStorage implements ObjectStorage {
247
418
  ContinuationToken: continuationToken,
248
419
  MaxKeys: S3_DELETE_PREFIX_BATCH_SIZE
249
420
  }),
250
- { abortSignal: signal }
421
+ { abortSignal: operation.signal }
251
422
  );
252
423
  } catch (error) {
253
- throw s3OperationError('list', fullPrefix, error);
424
+ throw operation.error(error);
254
425
  }
255
426
  }
256
427
 
257
428
  private async deleteFullPaths(fullPaths: { Key: string }[], signal?: AbortSignal): Promise<void> {
258
429
  if (fullPaths.length === 0) return;
259
- await using _ = await this.withOperation(signal);
430
+ await using operation = await this.withOperation('delete', `${fullPaths.length} objects`, signal);
260
431
  let response;
261
432
  try {
262
433
  response = await this.client.send(
@@ -264,10 +435,10 @@ export class S3ObjectStorage implements ObjectStorage {
264
435
  Bucket: this.bucket,
265
436
  Delete: { Objects: fullPaths, Quiet: true }
266
437
  }),
267
- { abortSignal: signal }
438
+ { abortSignal: operation.signal }
268
439
  );
269
440
  } catch (error) {
270
- throw s3OperationError('delete', `${fullPaths.length} objects`, error);
441
+ throw operation.error(error);
271
442
  }
272
443
  if (response.Errors?.length) {
273
444
  const errors = response.Errors.map((error) => `${error.Key}: ${error.Code}`).join(', ');
@@ -287,14 +458,28 @@ export class S3ObjectStorage implements ObjectStorage {
287
458
  return fullPath;
288
459
  }
289
460
 
290
- private async withOperation(signal?: AbortSignal): Promise<AsyncDisposable> {
461
+ private async withOperation(operation: string, target: string, signal?: AbortSignal): Promise<S3Operation> {
291
462
  signal?.throwIfAborted();
292
- const acquired = signal
293
- ? await acquireSemaphoreAbortable(this.operationSemaphore, signal)
294
- : await this.operationSemaphore.acquire();
463
+ const { operationTimeoutMs, queueTimeoutMs } = this.timeouts;
464
+
465
+ // Waiting for a slot is bounded even when the caller has no signal of its own, which is the
466
+ // case for uploads and deletes.
467
+ const queueDeadline = timeoutSignal(queueTimeoutMs, () =>
468
+ s3TimeoutError(operation, target, `timed out after ${queueTimeoutMs} ms waiting for a concurrency slot`)
469
+ );
470
+ let acquired;
471
+ try {
472
+ acquired = await acquireSemaphoreAbortable(
473
+ this.operationSemaphore,
474
+ signal ? AbortSignal.any([signal, queueDeadline.signal]) : queueDeadline.signal
475
+ );
476
+ } finally {
477
+ queueDeadline.cancel();
478
+ }
295
479
  if (acquired === 'aborted') {
296
- signal!.throwIfAborted();
297
- throw new Error('S3 operation aborted while waiting for a concurrency slot');
480
+ // A caller abort takes precedence: it is a cancellation, not an S3 failure.
481
+ signal?.throwIfAborted();
482
+ throw queueDeadline.signal.reason;
298
483
  }
299
484
 
300
485
  const [, release] = acquired;
@@ -302,12 +487,57 @@ export class S3ObjectStorage implements ObjectStorage {
302
487
  release();
303
488
  signal.throwIfAborted();
304
489
  }
490
+ // This deadline only starts once the operation holds a slot - queueing is covered above.
491
+ const deadline = timeoutSignal(operationTimeoutMs, () =>
492
+ s3TimeoutError(operation, target, `did not complete within ${operationTimeoutMs} ms`)
493
+ );
494
+ const operationSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
305
495
  return {
306
- [Symbol.asyncDispose]: async () => release()
496
+ signal: operationSignal,
497
+ error: (cause) => {
498
+ // Aborting a request that is already streaming destroys the socket, and the failure
499
+ // surfaces as a generic "aborted" error that says nothing about the cause. The abort
500
+ // reason is always the better answer: the caller's reason when the caller stopped us -
501
+ // callers abort with the error that stopped them, and upstream handling matches on that
502
+ // type - and an already-classified timeout when the deadline did.
503
+ if (operationSignal.aborted) {
504
+ return operationSignal.reason as Error;
505
+ }
506
+ return s3OperationError(operation, target, cause);
507
+ },
508
+ [Symbol.asyncDispose]: async () => {
509
+ deadline.cancel();
510
+ release();
511
+ }
307
512
  };
308
513
  }
309
514
  }
310
515
 
516
+ /**
517
+ * An abort signal that fires after the given timeout, using a timer that does not hold the process
518
+ * open.
519
+ *
520
+ * The reason is what callers see when the timeout fires, so it is the error to report - not a
521
+ * marker to translate later.
522
+ */
523
+ function timeoutSignal(timeoutMs: number, createReason: () => unknown): { signal: AbortSignal; cancel: () => void } {
524
+ const controller = new AbortController();
525
+ const timer = setTimeout(() => controller.abort(createReason()), timeoutMs);
526
+ timer.unref();
527
+ return { signal: controller.signal, cancel: () => clearTimeout(timer) };
528
+ }
529
+
530
+ /**
531
+ * A timeout that the compactor may retry, matching how the AWS SDK classifies its own request
532
+ * timeouts.
533
+ */
534
+ function s3TimeoutError(operation: string, target: string, message: string): ObjectStorageError {
535
+ return new ObjectStorageError(`S3 ${operation} for ${target} ${message}`, {
536
+ cause: new DOMException(message, 'TimeoutError'),
537
+ retryable: true
538
+ });
539
+ }
540
+
311
541
  function s3OperationError(operation: string, target: string, error: unknown): Error {
312
542
  if (error instanceof ObjectStorageError) {
313
543
  return error;