@uploadista/data-store-s3 0.0.3
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.
- package/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-check.log +5 -0
- package/LICENSE +21 -0
- package/README.md +588 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/observability.d.ts +45 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +155 -0
- package/dist/s3-store-old.d.ts +51 -0
- package/dist/s3-store-old.d.ts.map +1 -0
- package/dist/s3-store-old.js +765 -0
- package/dist/s3-store.d.ts +9 -0
- package/dist/s3-store.d.ts.map +1 -0
- package/dist/s3-store.js +666 -0
- package/dist/services/__mocks__/s3-client-mock.service.d.ts +44 -0
- package/dist/services/__mocks__/s3-client-mock.service.d.ts.map +1 -0
- package/dist/services/__mocks__/s3-client-mock.service.js +379 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/s3-client.service.d.ts +68 -0
- package/dist/services/s3-client.service.d.ts.map +1 -0
- package/dist/services/s3-client.service.js +209 -0
- package/dist/test-observability.d.ts +6 -0
- package/dist/test-observability.d.ts.map +1 -0
- package/dist/test-observability.js +62 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils/calculations.d.ts +7 -0
- package/dist/utils/calculations.d.ts.map +1 -0
- package/dist/utils/calculations.js +41 -0
- package/dist/utils/error-handling.d.ts +7 -0
- package/dist/utils/error-handling.d.ts.map +1 -0
- package/dist/utils/error-handling.js +29 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/stream-adapter.d.ts +14 -0
- package/dist/utils/stream-adapter.d.ts.map +1 -0
- package/dist/utils/stream-adapter.js +41 -0
- package/package.json +36 -0
- package/src/__tests__/integration/s3-store.integration.test.ts +548 -0
- package/src/__tests__/multipart-logic.test.ts +395 -0
- package/src/__tests__/s3-store.edge-cases.test.ts +681 -0
- package/src/__tests__/s3-store.performance.test.ts +622 -0
- package/src/__tests__/s3-store.test.ts +662 -0
- package/src/__tests__/utils/performance-helpers.ts +459 -0
- package/src/__tests__/utils/test-data-generator.ts +331 -0
- package/src/__tests__/utils/test-setup.ts +256 -0
- package/src/index.ts +1 -0
- package/src/s3-store.ts +1059 -0
- package/src/services/__mocks__/s3-client-mock.service.ts +604 -0
- package/src/services/index.ts +1 -0
- package/src/services/s3-client.service.ts +359 -0
- package/src/types.ts +96 -0
- package/src/utils/calculations.ts +61 -0
- package/src/utils/error-handling.ts +52 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/stream-adapter.ts +50 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { NoSuchKey, NotFound, S3 } from "@aws-sdk/client-s3";
|
|
2
|
+
import { UploadistaError } from "@uploadista/core/errors";
|
|
3
|
+
import { UploadFileDataStore, UploadFileKVStore } from "@uploadista/core/types";
|
|
4
|
+
import { s3ActiveUploadsGauge as activeUploadsGauge, s3FileSizeHistogram as fileSizeHistogram, trackS3Error as logS3Error, s3PartSizeHistogram as partSizeHistogram, s3PartUploadDurationHistogram as partUploadDurationHistogram, s3UploadDurationHistogram as uploadDurationHistogram, s3UploadErrorsTotal as uploadErrorsTotal, s3UploadPartsTotal as uploadPartsTotal, s3UploadRequestsTotal as uploadRequestsTotal, s3UploadSuccessTotal as uploadSuccessTotal, withS3ApiMetrics, withS3TimingMetrics as withTimingMetrics, withS3UploadMetrics as withUploadMetrics, } from "@uploadista/observability";
|
|
5
|
+
import { Effect, Layer, Ref, Schedule, Stream } from "effect";
|
|
6
|
+
// Proper single-pass chunking using Effect's async stream constructor
|
|
7
|
+
const createChunkedStream = (chunkSize) => (stream) => {
|
|
8
|
+
return Stream.async((emit) => {
|
|
9
|
+
let buffer = new Uint8Array(0);
|
|
10
|
+
let partNumber = 1;
|
|
11
|
+
const emitChunk = (data) => {
|
|
12
|
+
// Log chunk information for debugging
|
|
13
|
+
Effect.runSync(Effect.logDebug("Creating chunk").pipe(Effect.annotateLogs({
|
|
14
|
+
part_number: partNumber,
|
|
15
|
+
chunk_size: data.length,
|
|
16
|
+
expected_size: chunkSize,
|
|
17
|
+
})));
|
|
18
|
+
emit.single({
|
|
19
|
+
partNumber: partNumber++,
|
|
20
|
+
data,
|
|
21
|
+
size: data.length,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
const processChunk = (newData) => {
|
|
25
|
+
// Combine buffer with new data
|
|
26
|
+
const combined = new Uint8Array(buffer.length + newData.length);
|
|
27
|
+
combined.set(buffer);
|
|
28
|
+
combined.set(newData, buffer.length);
|
|
29
|
+
buffer = combined;
|
|
30
|
+
// Emit full chunks
|
|
31
|
+
while (buffer.length >= chunkSize) {
|
|
32
|
+
const chunk = buffer.slice(0, chunkSize);
|
|
33
|
+
buffer = buffer.slice(chunkSize);
|
|
34
|
+
emitChunk(chunk);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
// Process the stream
|
|
38
|
+
Effect.runFork(stream.pipe(Stream.runForEach((chunk) => Effect.sync(() => processChunk(chunk))), Effect.andThen(() => Effect.sync(() => {
|
|
39
|
+
// Emit final chunk if there's remaining data
|
|
40
|
+
if (buffer.length > 0) {
|
|
41
|
+
emitChunk(buffer);
|
|
42
|
+
}
|
|
43
|
+
emit.end();
|
|
44
|
+
})), Effect.catchAll((error) => Effect.sync(() => emit.fail(error)))));
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
// Progress tracking with side effects
|
|
48
|
+
const withProgressTracking = (onProgress) => (stream) => onProgress
|
|
49
|
+
? stream.pipe(Stream.tap((chunkInfo) => Effect.sync(() => onProgress(chunkInfo.size))))
|
|
50
|
+
: stream;
|
|
51
|
+
function calcOffsetFromParts(parts) {
|
|
52
|
+
return parts && parts.length > 0
|
|
53
|
+
? parts.reduce((a, b) => a + (b?.Size ?? 0), 0)
|
|
54
|
+
: 0;
|
|
55
|
+
}
|
|
56
|
+
export function createS3Storexx(options) {
|
|
57
|
+
return Effect.gen(function* () {
|
|
58
|
+
const kvStore = yield* UploadFileKVStore;
|
|
59
|
+
return createS3StoreImplementation({ ...options, kvStore });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Internal s3Store implementation (keeping existing logic)
|
|
63
|
+
function createS3StoreImplementation({ deliveryUrl, partSize, minPartSize = 5_242_880, useTags = true, maxMultipartParts = 10_000, kvStore, maxConcurrentPartUploads = 60, expirationPeriodInMilliseconds = 1000 * 60 * 60 * 24 * 7, // 1 week
|
|
64
|
+
s3ClientConfig: { bucket, ...restS3ClientConfig }, }) {
|
|
65
|
+
const preferredPartSize = partSize || 8 * 1024 * 1024;
|
|
66
|
+
const maxUploadSize = 5_497_558_138_880; // 5TiB
|
|
67
|
+
const client = new S3(restS3ClientConfig);
|
|
68
|
+
const shouldUseExpirationTags = () => {
|
|
69
|
+
return expirationPeriodInMilliseconds !== 0 && useTags;
|
|
70
|
+
};
|
|
71
|
+
const completeMetadata = (upload) => {
|
|
72
|
+
return Effect.gen(function* () {
|
|
73
|
+
if (!shouldUseExpirationTags()) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const uploadFile = yield* kvStore.get(upload.id);
|
|
77
|
+
const uploadId = uploadFile.storage.uploadId;
|
|
78
|
+
if (!uploadId) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
yield* kvStore.set(upload.id, {
|
|
82
|
+
...uploadFile,
|
|
83
|
+
storage: { ...uploadFile.storage, uploadId },
|
|
84
|
+
});
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const partKey = (id) => {
|
|
89
|
+
return `${id}.part`;
|
|
90
|
+
};
|
|
91
|
+
const uploadPart = (uploadFile, data, partNumber) => withS3ApiMetrics("uploadPart", withTimingMetrics(partUploadDurationHistogram, Effect.tryPromise({
|
|
92
|
+
try: () => client.uploadPart({
|
|
93
|
+
Bucket: bucket,
|
|
94
|
+
Key: uploadFile.id,
|
|
95
|
+
UploadId: uploadFile.storage.uploadId,
|
|
96
|
+
PartNumber: partNumber,
|
|
97
|
+
Body: data,
|
|
98
|
+
}),
|
|
99
|
+
catch: (error) => {
|
|
100
|
+
// Use enhanced error tracking
|
|
101
|
+
Effect.runSync(logS3Error("uploadPart", error, {
|
|
102
|
+
upload_id: uploadFile.id,
|
|
103
|
+
part_number: partNumber,
|
|
104
|
+
part_size: data.length,
|
|
105
|
+
s3_bucket: bucket,
|
|
106
|
+
}));
|
|
107
|
+
return UploadistaError.fromCode("FILE_WRITE_ERROR", error);
|
|
108
|
+
},
|
|
109
|
+
}).pipe(Effect.retry(Schedule.exponential("1 second", 2.0).pipe(Schedule.intersect(Schedule.recurs(3)))), Effect.tapError((error) => Effect.logWarning("Retrying part upload").pipe(Effect.annotateLogs({
|
|
110
|
+
upload_id: uploadFile.id,
|
|
111
|
+
part_number: partNumber,
|
|
112
|
+
error_message: error.message,
|
|
113
|
+
retry_attempt: "unknown", // Will be overridden by the retry schedule
|
|
114
|
+
part_size: data.length,
|
|
115
|
+
s3_bucket: bucket,
|
|
116
|
+
}))), Effect.map((response) => response.ETag), Effect.tap(() => uploadPartsTotal(Effect.succeed(1))), Effect.tap(() => Effect.logInfo("Part uploaded successfully").pipe(Effect.annotateLogs({
|
|
117
|
+
upload_id: uploadFile.id,
|
|
118
|
+
part_number: partNumber,
|
|
119
|
+
})))))).pipe(Effect.withSpan(`s3-upload-part-${partNumber}`, {
|
|
120
|
+
attributes: {
|
|
121
|
+
"upload.id": uploadFile.id,
|
|
122
|
+
"upload.part_number": partNumber,
|
|
123
|
+
"upload.part_size": data.length,
|
|
124
|
+
"s3.bucket": bucket,
|
|
125
|
+
"s3.key": uploadFile.id,
|
|
126
|
+
},
|
|
127
|
+
}));
|
|
128
|
+
const uploadIncompletePart = (id, data) => Effect.tryPromise({
|
|
129
|
+
try: () => client.putObject({
|
|
130
|
+
Bucket: bucket,
|
|
131
|
+
Key: partKey(id),
|
|
132
|
+
Body: data,
|
|
133
|
+
}),
|
|
134
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
135
|
+
}).pipe(Effect.flatMap((response) => response.ETag
|
|
136
|
+
? Effect.succeed(response.ETag)
|
|
137
|
+
: Effect.fail(UploadistaError.fromCode("FILE_WRITE_ERROR", new Error("ETag is undefined when uploading incomplete part")))), Effect.tap(() => Effect.logInfo("Incomplete part uploaded").pipe(Effect.annotateLogs({ upload_id: id }))));
|
|
138
|
+
const getIncompletePart = (id) => {
|
|
139
|
+
return Effect.tryPromise({
|
|
140
|
+
try: async () => {
|
|
141
|
+
try {
|
|
142
|
+
const data = await client.getObject({
|
|
143
|
+
Bucket: bucket,
|
|
144
|
+
Key: partKey(id),
|
|
145
|
+
});
|
|
146
|
+
return data.Body;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof NoSuchKey) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const getIncompletePartSize = (id) => {
|
|
159
|
+
return Effect.tryPromise({
|
|
160
|
+
try: async () => {
|
|
161
|
+
try {
|
|
162
|
+
const data = await client.headObject({
|
|
163
|
+
Bucket: bucket,
|
|
164
|
+
Key: partKey(id),
|
|
165
|
+
});
|
|
166
|
+
return data.ContentLength;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof NotFound) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
const deleteIncompletePart = (id) => {
|
|
179
|
+
return Effect.tryPromise({
|
|
180
|
+
try: async () => {
|
|
181
|
+
await client.deleteObject({
|
|
182
|
+
Bucket: bucket,
|
|
183
|
+
Key: partKey(id),
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
const downloadIncompletePart = (id) => {
|
|
190
|
+
return Effect.gen(function* () {
|
|
191
|
+
const incompletePart = yield* getIncompletePart(id);
|
|
192
|
+
if (!incompletePart) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Read the stream and collect all chunks to calculate size
|
|
196
|
+
const reader = incompletePart.getReader();
|
|
197
|
+
const chunks = [];
|
|
198
|
+
let incompletePartSize = 0;
|
|
199
|
+
try {
|
|
200
|
+
while (true) {
|
|
201
|
+
const { done, value } = yield* Effect.promise(() => reader.read());
|
|
202
|
+
if (done)
|
|
203
|
+
break;
|
|
204
|
+
chunks.push(value);
|
|
205
|
+
incompletePartSize += value.length;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
}
|
|
211
|
+
const stream = Stream.fromIterable(chunks);
|
|
212
|
+
return {
|
|
213
|
+
size: incompletePartSize,
|
|
214
|
+
stream,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const calcOptimalPartSize = (initSize) => {
|
|
219
|
+
const size = initSize ?? maxUploadSize;
|
|
220
|
+
let optimalPartSize;
|
|
221
|
+
if (size <= preferredPartSize) {
|
|
222
|
+
optimalPartSize = size;
|
|
223
|
+
}
|
|
224
|
+
else if (size <= preferredPartSize * maxMultipartParts) {
|
|
225
|
+
optimalPartSize = preferredPartSize;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// Calculate the minimum part size needed to fit within the max parts limit
|
|
229
|
+
optimalPartSize = Math.ceil(size / maxMultipartParts);
|
|
230
|
+
}
|
|
231
|
+
// For small files (smaller than minPartSize), use the actual file size
|
|
232
|
+
// to avoid unnecessary chunking. Only enforce minPartSize for larger files.
|
|
233
|
+
const finalPartSize = initSize && initSize < minPartSize
|
|
234
|
+
? optimalPartSize // Use actual file size for small files
|
|
235
|
+
: Math.max(optimalPartSize, minPartSize);
|
|
236
|
+
// Round up to ensure consistent part sizes (align to chunk boundaries)
|
|
237
|
+
// This ensures all parts except the last one will have exactly the same size
|
|
238
|
+
return Math.ceil(finalPartSize / 1024) * 1024; // Align to 1KB boundaries
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* Uploads a stream to S3 using multiple parts with streaming sink pattern
|
|
242
|
+
*/
|
|
243
|
+
const uploadParts = (uploadFile, readStream, initCurrentPartNumber, initOffset, onProgress) => Effect.gen(function* () {
|
|
244
|
+
const uploadPartSize = calcOptimalPartSize(uploadFile.size);
|
|
245
|
+
yield* Effect.logInfo("Starting part uploads").pipe(Effect.annotateLogs({
|
|
246
|
+
upload_id: uploadFile.id,
|
|
247
|
+
init_offset: initOffset,
|
|
248
|
+
file_size: uploadFile.size,
|
|
249
|
+
part_size: uploadPartSize,
|
|
250
|
+
min_part_size: minPartSize,
|
|
251
|
+
}));
|
|
252
|
+
// Run the streaming pipeline
|
|
253
|
+
const chunkStream = readStream.pipe(createChunkedStream(uploadPartSize), withProgressTracking(onProgress
|
|
254
|
+
? (_chunkSize) => {
|
|
255
|
+
// Progress tracking handled via the sink's Ref updates
|
|
256
|
+
// The actual progress reporting happens in uploadChunk
|
|
257
|
+
}
|
|
258
|
+
: undefined));
|
|
259
|
+
// Track cumulative offset and total bytes with Effect Refs
|
|
260
|
+
const cumulativeOffsetRef = yield* Ref.make(initOffset);
|
|
261
|
+
const totalBytesUploadedRef = yield* Ref.make(0);
|
|
262
|
+
// Create a chunk upload function for the sink
|
|
263
|
+
const uploadChunk = (chunkInfo) => Effect.gen(function* () {
|
|
264
|
+
// Calculate cumulative bytes to determine if this is the final part
|
|
265
|
+
const cumulativeOffset = yield* Ref.updateAndGet(cumulativeOffsetRef, (offset) => offset + chunkInfo.size);
|
|
266
|
+
const isFinalPart = cumulativeOffset >= (uploadFile.size || 0);
|
|
267
|
+
yield* Effect.logDebug("Processing chunk").pipe(Effect.annotateLogs({
|
|
268
|
+
upload_id: uploadFile.id,
|
|
269
|
+
cumulative_offset: cumulativeOffset,
|
|
270
|
+
file_size: uploadFile.size,
|
|
271
|
+
chunk_size: chunkInfo.size,
|
|
272
|
+
is_final_part: isFinalPart,
|
|
273
|
+
}));
|
|
274
|
+
const actualPartNumber = initCurrentPartNumber + chunkInfo.partNumber - 1;
|
|
275
|
+
if (chunkInfo.size > uploadPartSize) {
|
|
276
|
+
yield* Effect.fail(UploadistaError.fromCode("FILE_WRITE_ERROR", new Error(`Part size ${chunkInfo.size} exceeds upload part size ${uploadPartSize}`)));
|
|
277
|
+
}
|
|
278
|
+
// For parts that meet the minimum part size (5MB) or are the final part,
|
|
279
|
+
// upload them as regular multipart parts
|
|
280
|
+
if (chunkInfo.size >= minPartSize || isFinalPart) {
|
|
281
|
+
yield* Effect.logDebug("Uploading multipart chunk").pipe(Effect.annotateLogs({
|
|
282
|
+
upload_id: uploadFile.id,
|
|
283
|
+
part_number: actualPartNumber,
|
|
284
|
+
chunk_size: chunkInfo.size,
|
|
285
|
+
min_part_size: minPartSize,
|
|
286
|
+
is_final_part: isFinalPart,
|
|
287
|
+
}));
|
|
288
|
+
yield* uploadPart(uploadFile, chunkInfo.data, actualPartNumber);
|
|
289
|
+
yield* partSizeHistogram(Effect.succeed(chunkInfo.size));
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
// Only upload as incomplete part if it's smaller than minimum and not final
|
|
293
|
+
yield* uploadIncompletePart(uploadFile.id, chunkInfo.data);
|
|
294
|
+
}
|
|
295
|
+
yield* Ref.update(totalBytesUploadedRef, (total) => total + chunkInfo.size);
|
|
296
|
+
// Report progress if callback provided
|
|
297
|
+
if (onProgress && cumulativeOffset > initOffset) {
|
|
298
|
+
onProgress(cumulativeOffset);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// Process chunks concurrently with controlled concurrency
|
|
302
|
+
yield* chunkStream.pipe(Stream.runForEach((chunkInfo) => uploadChunk(chunkInfo)), Effect.withConcurrency(maxConcurrentPartUploads));
|
|
303
|
+
return yield* Ref.get(totalBytesUploadedRef);
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Completes a multipart upload on S3.
|
|
307
|
+
* This is where S3 concatenates all the uploaded parts.
|
|
308
|
+
*/
|
|
309
|
+
const finishMultipartUpload = (uploadFile, parts) => {
|
|
310
|
+
return withS3ApiMetrics("completeMultipartUpload", Effect.tryPromise({
|
|
311
|
+
try: () => client
|
|
312
|
+
.completeMultipartUpload({
|
|
313
|
+
Bucket: bucket,
|
|
314
|
+
Key: uploadFile.id,
|
|
315
|
+
UploadId: uploadFile.storage.uploadId,
|
|
316
|
+
MultipartUpload: {
|
|
317
|
+
Parts: parts.map((part) => {
|
|
318
|
+
return {
|
|
319
|
+
ETag: part.ETag,
|
|
320
|
+
PartNumber: part.PartNumber,
|
|
321
|
+
};
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
.then((response) => response.Location),
|
|
326
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
327
|
+
})).pipe(Effect.tap(() => uploadSuccessTotal(Effect.succeed(1))), Effect.withSpan("s3-complete-multipart-upload", {
|
|
328
|
+
attributes: {
|
|
329
|
+
"upload.id": uploadFile.id,
|
|
330
|
+
"upload.parts_count": parts.length,
|
|
331
|
+
"s3.bucket": bucket,
|
|
332
|
+
"s3.key": uploadFile.id,
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
};
|
|
336
|
+
const retrievePartsS3 = async ({ id, uploadId, partNumberMarker, }) => {
|
|
337
|
+
try {
|
|
338
|
+
const params = {
|
|
339
|
+
Bucket: bucket,
|
|
340
|
+
Key: id,
|
|
341
|
+
UploadId: uploadId,
|
|
342
|
+
PartNumberMarker: partNumberMarker,
|
|
343
|
+
};
|
|
344
|
+
const data = await client.listParts(params);
|
|
345
|
+
let parts = data.Parts ?? [];
|
|
346
|
+
if (data.IsTruncated) {
|
|
347
|
+
const rest = await retrievePartsS3({
|
|
348
|
+
id,
|
|
349
|
+
uploadId,
|
|
350
|
+
partNumberMarker: data.NextPartNumberMarker,
|
|
351
|
+
});
|
|
352
|
+
parts = [...parts, ...rest.parts];
|
|
353
|
+
}
|
|
354
|
+
if (!partNumberMarker) {
|
|
355
|
+
parts.sort((a, b) => (a.PartNumber ?? 0) - (b.PartNumber ?? 0));
|
|
356
|
+
}
|
|
357
|
+
return { uploadFound: true, parts };
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
// Check if the error is caused by the upload not being found. This happens
|
|
361
|
+
// when the multipart upload has already been completed or aborted. Since
|
|
362
|
+
// we already found the info object, we know that the upload has been
|
|
363
|
+
// completed and therefore can ensure the the offset is the size.
|
|
364
|
+
// AWS S3 returns NoSuchUpload, but other implementations, such as DigitalOcean
|
|
365
|
+
// Spaces, can also return NoSuchKey.
|
|
366
|
+
if (typeof error === "object" &&
|
|
367
|
+
error !== null &&
|
|
368
|
+
"code" in error &&
|
|
369
|
+
typeof error.code === "string" &&
|
|
370
|
+
(error.code === "NoSuchUpload" || error.code === "NoSuchKey")) {
|
|
371
|
+
Effect.runSync(Effect.logWarning("S3 upload not found during listParts").pipe(Effect.annotateLogs({
|
|
372
|
+
upload_id: id,
|
|
373
|
+
error_code: error.code,
|
|
374
|
+
})));
|
|
375
|
+
return { uploadFound: false, parts: [] };
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const getUploadId = (uploadFile) => {
|
|
381
|
+
const uploadId = uploadFile.storage.uploadId;
|
|
382
|
+
if (!uploadId) {
|
|
383
|
+
return Effect.fail(UploadistaError.fromCode("FILE_WRITE_ERROR", new Error("Upload ID is undefined")));
|
|
384
|
+
}
|
|
385
|
+
return Effect.succeed(uploadId);
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Gets the number of complete parts/chunks already uploaded to S3.
|
|
389
|
+
* Retrieves only consecutive parts.
|
|
390
|
+
*/
|
|
391
|
+
const retrieveParts = (id, partNumberMarker) => {
|
|
392
|
+
return Effect.gen(function* () {
|
|
393
|
+
const metadata = yield* kvStore.get(id);
|
|
394
|
+
const uploadId = yield* getUploadId(metadata);
|
|
395
|
+
const { parts, uploadFound } = yield* Effect.promise(() => retrievePartsS3({ id, uploadId, partNumberMarker }));
|
|
396
|
+
return { uploadFound, parts };
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* Removes cached data for a given file.
|
|
401
|
+
*/
|
|
402
|
+
const clearCache = (id) => {
|
|
403
|
+
return Effect.gen(function* () {
|
|
404
|
+
yield* Effect.logInfo("Clearing cache").pipe(Effect.annotateLogs({ upload_id: id }));
|
|
405
|
+
yield* kvStore.delete(id);
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
const createMultipartUpload = (upload) => {
|
|
409
|
+
return withS3ApiMetrics("createMultipartUpload", Effect.tryPromise({
|
|
410
|
+
try: async () => {
|
|
411
|
+
const request = {
|
|
412
|
+
Bucket: bucket,
|
|
413
|
+
Key: upload.id,
|
|
414
|
+
};
|
|
415
|
+
if (upload.metadata?.contentType) {
|
|
416
|
+
request.ContentType = upload.metadata.contentType;
|
|
417
|
+
}
|
|
418
|
+
if (upload.metadata?.cacheControl) {
|
|
419
|
+
request.CacheControl = upload.metadata.cacheControl;
|
|
420
|
+
}
|
|
421
|
+
const res = await client.createMultipartUpload(request);
|
|
422
|
+
upload.storage = {
|
|
423
|
+
id: upload.storage.id,
|
|
424
|
+
type: upload.storage.type,
|
|
425
|
+
path: res.Key,
|
|
426
|
+
uploadId: res.UploadId,
|
|
427
|
+
bucket,
|
|
428
|
+
};
|
|
429
|
+
upload.url = `${deliveryUrl}/${upload.id}`;
|
|
430
|
+
return upload;
|
|
431
|
+
},
|
|
432
|
+
catch: (error) => {
|
|
433
|
+
Effect.runSync(logS3Error("createMultipartUpload", error, {
|
|
434
|
+
upload_id: upload.id,
|
|
435
|
+
s3_bucket: bucket,
|
|
436
|
+
content_type: upload.metadata?.contentType,
|
|
437
|
+
file_size: upload.size,
|
|
438
|
+
}));
|
|
439
|
+
return UploadistaError.fromCode("FILE_WRITE_ERROR", error);
|
|
440
|
+
},
|
|
441
|
+
})).pipe(Effect.tap(() => fileSizeHistogram(Effect.succeed(upload.size || 0))), Effect.withSpan("s3-create-multipart-upload", {
|
|
442
|
+
attributes: {
|
|
443
|
+
"upload.id": upload.id,
|
|
444
|
+
"upload.size": upload.size || 0,
|
|
445
|
+
"upload.content_type": upload.metadata?.contentType || "unknown",
|
|
446
|
+
"s3.bucket": bucket,
|
|
447
|
+
"s3.key": upload.id,
|
|
448
|
+
},
|
|
449
|
+
}));
|
|
450
|
+
};
|
|
451
|
+
/**
|
|
452
|
+
* Creates a multipart upload on S3 attaching any metadata to it.
|
|
453
|
+
* Also, a `${file_id}.info` file is created which holds some information
|
|
454
|
+
* about the upload itself like: `upload-id`, `upload-length`, etc.
|
|
455
|
+
*/
|
|
456
|
+
const create = (upload) => {
|
|
457
|
+
return Effect.gen(function* () {
|
|
458
|
+
yield* Effect.logInfo("Initializing multipart upload").pipe(Effect.annotateLogs({ upload_id: upload.id }));
|
|
459
|
+
const uploadCreated = yield* createMultipartUpload(upload);
|
|
460
|
+
yield* kvStore.set(upload.id, uploadCreated);
|
|
461
|
+
yield* Effect.logInfo("Multipart upload created").pipe(Effect.annotateLogs({
|
|
462
|
+
upload_id: upload.id,
|
|
463
|
+
s3_upload_id: uploadCreated.storage.uploadId,
|
|
464
|
+
}));
|
|
465
|
+
yield* uploadRequestsTotal(Effect.succeed(1));
|
|
466
|
+
return uploadCreated;
|
|
467
|
+
}).pipe(Effect.withSpan("s3-create-upload", {
|
|
468
|
+
attributes: {
|
|
469
|
+
"upload.id": upload.id,
|
|
470
|
+
"upload.size": upload.size || 0,
|
|
471
|
+
"s3.bucket": bucket,
|
|
472
|
+
},
|
|
473
|
+
}));
|
|
474
|
+
};
|
|
475
|
+
const read = (id) => {
|
|
476
|
+
return Effect.tryPromise({
|
|
477
|
+
try: async () => {
|
|
478
|
+
const data = await client.getObject({
|
|
479
|
+
Bucket: bucket,
|
|
480
|
+
Key: id,
|
|
481
|
+
});
|
|
482
|
+
return data.Body;
|
|
483
|
+
},
|
|
484
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
const prepareUpload = (file_id, initialOffset, initialData) => {
|
|
488
|
+
return Effect.gen(function* () {
|
|
489
|
+
const uploadFile = yield* kvStore.get(file_id);
|
|
490
|
+
yield* Effect.logDebug("Retrieved upload metadata").pipe(Effect.annotateLogs({
|
|
491
|
+
upload_id: file_id,
|
|
492
|
+
metadata: uploadFile,
|
|
493
|
+
}));
|
|
494
|
+
const { parts } = yield* retrieveParts(file_id);
|
|
495
|
+
yield* Effect.logDebug("Retrieved existing parts").pipe(Effect.annotateLogs({
|
|
496
|
+
upload_id: file_id,
|
|
497
|
+
parts_count: parts.length,
|
|
498
|
+
parts: parts.map((p) => ({ part: p.PartNumber, size: p.Size })),
|
|
499
|
+
}));
|
|
500
|
+
const partNumber = parts.length > 0 && parts[parts.length - 1].PartNumber
|
|
501
|
+
? (parts[parts.length - 1].PartNumber ?? 0)
|
|
502
|
+
: 0;
|
|
503
|
+
const nextPartNumber = partNumber + 1;
|
|
504
|
+
const incompletePart = yield* downloadIncompletePart(file_id);
|
|
505
|
+
if (incompletePart) {
|
|
506
|
+
yield* Effect.logDebug("Found incomplete part").pipe(Effect.annotateLogs({
|
|
507
|
+
upload_id: file_id,
|
|
508
|
+
incomplete_part_size: incompletePart.size,
|
|
509
|
+
}));
|
|
510
|
+
yield* deleteIncompletePart(file_id);
|
|
511
|
+
const offset = initialOffset - incompletePart.size;
|
|
512
|
+
const data = incompletePart.stream.pipe(Stream.concat(initialData));
|
|
513
|
+
return {
|
|
514
|
+
uploadFile,
|
|
515
|
+
nextPartNumber, // Use the current next part number - incomplete part merges with new data
|
|
516
|
+
offset,
|
|
517
|
+
incompletePartSize: incompletePart.size,
|
|
518
|
+
data,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
return {
|
|
523
|
+
uploadFile,
|
|
524
|
+
nextPartNumber,
|
|
525
|
+
offset: initialOffset,
|
|
526
|
+
incompletePartSize: 0,
|
|
527
|
+
data: initialData,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* Write to the file, starting at the provided offset
|
|
534
|
+
*/
|
|
535
|
+
const write = (options, dependencies) => {
|
|
536
|
+
return withUploadMetrics(options.file_id, withTimingMetrics(uploadDurationHistogram, Effect.gen(function* () {
|
|
537
|
+
const { stream: initialData, file_id, offset: initialOffset, } = options;
|
|
538
|
+
const { onProgress } = dependencies;
|
|
539
|
+
// Track active upload
|
|
540
|
+
yield* activeUploadsGauge(Effect.succeed(1));
|
|
541
|
+
const prepareResult = yield* prepareUpload(file_id, initialOffset, initialData);
|
|
542
|
+
const { uploadFile, nextPartNumber, offset, data } = prepareResult;
|
|
543
|
+
const bytesUploaded = yield* uploadParts(uploadFile, data, nextPartNumber, offset, onProgress);
|
|
544
|
+
const newOffset = offset + bytesUploaded;
|
|
545
|
+
if (uploadFile.size === newOffset) {
|
|
546
|
+
const finishUploadEffect = Effect.gen(function* () {
|
|
547
|
+
const { parts } = yield* retrieveParts(file_id);
|
|
548
|
+
yield* finishMultipartUpload(uploadFile, parts);
|
|
549
|
+
yield* completeMetadata(uploadFile);
|
|
550
|
+
yield* clearCache(file_id);
|
|
551
|
+
});
|
|
552
|
+
yield* finishUploadEffect.pipe(Effect.tapError((error) => {
|
|
553
|
+
return Effect.gen(function* () {
|
|
554
|
+
yield* uploadErrorsTotal(Effect.succeed(1));
|
|
555
|
+
yield* Effect.logError("Failed to finish upload").pipe(Effect.annotateLogs({
|
|
556
|
+
upload_id: file_id,
|
|
557
|
+
error: String(error),
|
|
558
|
+
}));
|
|
559
|
+
});
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
return newOffset;
|
|
563
|
+
}).pipe(Effect.ensuring(activeUploadsGauge(Effect.succeed(0)))))).pipe(Effect.withSpan("s3-upload-write", {
|
|
564
|
+
attributes: {
|
|
565
|
+
"upload.id": options.file_id,
|
|
566
|
+
"upload.offset": options.offset,
|
|
567
|
+
"s3.bucket": bucket,
|
|
568
|
+
},
|
|
569
|
+
}));
|
|
570
|
+
};
|
|
571
|
+
const getUpload = (id) => {
|
|
572
|
+
return Effect.gen(function* () {
|
|
573
|
+
const uploadFile = yield* kvStore.get(id);
|
|
574
|
+
let offset = 0;
|
|
575
|
+
const { parts, uploadFound } = yield* retrieveParts(id);
|
|
576
|
+
if (!uploadFound) {
|
|
577
|
+
return {
|
|
578
|
+
...uploadFile,
|
|
579
|
+
offset: uploadFile.size,
|
|
580
|
+
size: uploadFile.size,
|
|
581
|
+
metadata: uploadFile.metadata,
|
|
582
|
+
storage: uploadFile.storage,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
offset = calcOffsetFromParts(parts);
|
|
586
|
+
const incompletePartSize = yield* getIncompletePartSize(id);
|
|
587
|
+
return {
|
|
588
|
+
...uploadFile,
|
|
589
|
+
offset: offset + (incompletePartSize ?? 0),
|
|
590
|
+
size: uploadFile.size,
|
|
591
|
+
storage: uploadFile.storage,
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
const abortMultipartUploadAndDeleteObjects = (id, uploadId) => {
|
|
596
|
+
return Effect.tryPromise({
|
|
597
|
+
try: async () => {
|
|
598
|
+
await client.abortMultipartUpload({
|
|
599
|
+
Bucket: bucket,
|
|
600
|
+
Key: id,
|
|
601
|
+
UploadId: uploadId,
|
|
602
|
+
});
|
|
603
|
+
await client.deleteObjects({
|
|
604
|
+
Bucket: bucket,
|
|
605
|
+
Delete: {
|
|
606
|
+
Objects: [{ Key: id }],
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
catch: (error) => {
|
|
611
|
+
if (typeof error === "object" &&
|
|
612
|
+
error !== null &&
|
|
613
|
+
"code" in error &&
|
|
614
|
+
typeof error.code === "string" &&
|
|
615
|
+
["NotFound", "NoSuchKey", "NoSuchUpload"].includes(error.code)) {
|
|
616
|
+
Effect.runSync(Effect.logWarning("File not found during remove operation").pipe(Effect.annotateLogs({
|
|
617
|
+
upload_id: id,
|
|
618
|
+
error_code: error.code,
|
|
619
|
+
})));
|
|
620
|
+
return UploadistaError.fromCode("FILE_NOT_FOUND");
|
|
621
|
+
}
|
|
622
|
+
return UploadistaError.fromCode("FILE_WRITE_ERROR", error);
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
const remove = (id) => {
|
|
627
|
+
return Effect.gen(function* () {
|
|
628
|
+
const uploadFile = yield* kvStore.get(id);
|
|
629
|
+
const uploadId = yield* getUploadId(uploadFile);
|
|
630
|
+
yield* abortMultipartUploadAndDeleteObjects(id, uploadId);
|
|
631
|
+
yield* clearCache(id);
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
const getExpiration = () => {
|
|
635
|
+
return expirationPeriodInMilliseconds;
|
|
636
|
+
};
|
|
637
|
+
const getExpirationDate = (created_at) => {
|
|
638
|
+
const date = new Date(created_at);
|
|
639
|
+
return new Date(date.getTime() + getExpiration());
|
|
640
|
+
};
|
|
641
|
+
const deleteExpired = () => {
|
|
642
|
+
return Effect.tryPromise({
|
|
643
|
+
try: async () => {
|
|
644
|
+
if (getExpiration() === 0) {
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
let keyMarker;
|
|
648
|
+
let uploadIdMarker;
|
|
649
|
+
let isTruncated = true;
|
|
650
|
+
let deleted = 0;
|
|
651
|
+
while (isTruncated) {
|
|
652
|
+
const listResponse = await client.listMultipartUploads({
|
|
653
|
+
Bucket: bucket,
|
|
654
|
+
KeyMarker: keyMarker,
|
|
655
|
+
UploadIdMarker: uploadIdMarker,
|
|
656
|
+
});
|
|
657
|
+
const expiredUploads = listResponse.Uploads?.filter((multiPartUpload) => {
|
|
658
|
+
const initiatedDate = multiPartUpload.Initiated;
|
|
659
|
+
return (initiatedDate &&
|
|
660
|
+
Date.now() >
|
|
661
|
+
getExpirationDate(initiatedDate.toISOString()).getTime());
|
|
662
|
+
}) || [];
|
|
663
|
+
const objectsToDelete = expiredUploads.reduce((all, expiredUpload) => {
|
|
664
|
+
all.push({
|
|
665
|
+
key: partKey(expiredUpload.Key),
|
|
666
|
+
});
|
|
667
|
+
return all;
|
|
668
|
+
}, []);
|
|
669
|
+
const deletions = [];
|
|
670
|
+
// Batch delete 1000 items at a time
|
|
671
|
+
while (objectsToDelete.length > 0) {
|
|
672
|
+
const objects = objectsToDelete.splice(0, 1000);
|
|
673
|
+
deletions.push(client.deleteObjects({
|
|
674
|
+
Bucket: bucket,
|
|
675
|
+
Delete: {
|
|
676
|
+
Objects: objects.map((object) => ({
|
|
677
|
+
Key: object.key,
|
|
678
|
+
})),
|
|
679
|
+
},
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
const [objectsDeleted] = await Promise.all([
|
|
683
|
+
Promise.all(deletions),
|
|
684
|
+
...expiredUploads.map((expiredUpload) => {
|
|
685
|
+
return client.abortMultipartUpload({
|
|
686
|
+
Bucket: bucket,
|
|
687
|
+
Key: expiredUpload.Key,
|
|
688
|
+
UploadId: expiredUpload.UploadId,
|
|
689
|
+
});
|
|
690
|
+
}),
|
|
691
|
+
]);
|
|
692
|
+
deleted += objectsDeleted.reduce((all, acc) => all + (acc.Deleted?.length ?? 0), 0);
|
|
693
|
+
isTruncated = listResponse.IsTruncated ?? false;
|
|
694
|
+
if (isTruncated) {
|
|
695
|
+
keyMarker = listResponse.NextKeyMarker;
|
|
696
|
+
uploadIdMarker = listResponse.NextUploadIdMarker;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return deleted;
|
|
700
|
+
},
|
|
701
|
+
catch: (error) => UploadistaError.fromCode("FILE_WRITE_ERROR", error),
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
const getCapabilities = () => {
|
|
705
|
+
return {
|
|
706
|
+
supportsParallelUploads: true,
|
|
707
|
+
supportsConcatenation: true,
|
|
708
|
+
supportsDeferredLength: true,
|
|
709
|
+
supportsResumableUploads: true,
|
|
710
|
+
supportsTransactionalUploads: true,
|
|
711
|
+
maxConcurrentUploads: maxConcurrentPartUploads,
|
|
712
|
+
minChunkSize: minPartSize,
|
|
713
|
+
maxChunkSize: 5_368_709_120, // 5GiB S3 limit
|
|
714
|
+
maxParts: maxMultipartParts,
|
|
715
|
+
optimalChunkSize: preferredPartSize,
|
|
716
|
+
requiresOrderedChunks: false,
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
const getChunkerConstraints = () => {
|
|
720
|
+
return {
|
|
721
|
+
minChunkSize: minPartSize,
|
|
722
|
+
maxChunkSize: 5_368_709_120, // 5GiB S3 limit
|
|
723
|
+
optimalChunkSize: preferredPartSize,
|
|
724
|
+
requiresOrderedChunks: false,
|
|
725
|
+
};
|
|
726
|
+
};
|
|
727
|
+
const validateUploadStrategy = (strategy) => {
|
|
728
|
+
const capabilities = getCapabilities();
|
|
729
|
+
const result = (() => {
|
|
730
|
+
switch (strategy) {
|
|
731
|
+
case "parallel":
|
|
732
|
+
return capabilities.supportsParallelUploads;
|
|
733
|
+
case "single":
|
|
734
|
+
return true;
|
|
735
|
+
default:
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
})();
|
|
739
|
+
return Effect.succeed(result);
|
|
740
|
+
};
|
|
741
|
+
return {
|
|
742
|
+
bucket,
|
|
743
|
+
create,
|
|
744
|
+
remove,
|
|
745
|
+
write,
|
|
746
|
+
getUpload,
|
|
747
|
+
read,
|
|
748
|
+
deleteExpired: deleteExpired(),
|
|
749
|
+
getCapabilities,
|
|
750
|
+
getChunkerConstraints,
|
|
751
|
+
validateUploadStrategy,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
// Effect-based factory that uses services
|
|
755
|
+
export const createS3Store = (options) => Effect.gen(function* () {
|
|
756
|
+
const kvStore = yield* UploadFileKVStore;
|
|
757
|
+
return createS3StoreImplementation({
|
|
758
|
+
...options,
|
|
759
|
+
kvStore,
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
// Layer for providing the S3Store service
|
|
763
|
+
export const S3StoreLayer = (options) => Layer.effect(UploadFileDataStore, createS3Store(options));
|
|
764
|
+
// Backward compatibility: keep the original function for existing code
|
|
765
|
+
export const s3Store = createS3StoreImplementation;
|