Skip to content

Commit b5288e6

Browse files
authored
fix(lib-storage): call AbortMultipartUpload when failing to CompleteMultipartUpload (#6112)
* fix(lib-storage): call AbortMultipartUpload when failing to CompleteMultipartUpload * fix(lib-storage): update parts count up front to detect breach correctly * fix(lib-storage): throw on multiple call to .done() method
1 parent b97d070 commit b5288e6

File tree

4 files changed

+232
-106
lines changed

4 files changed

+232
-106
lines changed

Diff for: lib/lib-storage/README.md

+15-4
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,23 @@ try {
1616
client: new S3({}) || new S3Client({}),
1717
params: { Bucket, Key, Body },
1818

19+
// optional tags
1920
tags: [
2021
/*...*/
21-
], // optional tags
22-
queueSize: 4, // optional concurrency configuration
23-
partSize: 1024 * 1024 * 5, // optional size of each part, in bytes, at least 5MB
24-
leavePartsOnError: false, // optional manually handle dropped parts
22+
],
23+
24+
// additional optional fields show default values below:
25+
26+
// (optional) concurrency configuration
27+
queueSize: 4,
28+
29+
// (optional) size of each part, in bytes, at least 5MB
30+
partSize: 1024 * 1024 * 5,
31+
32+
// (optional) when true, do not automatically call AbortMultipartUpload when
33+
// a multipart upload fails to complete. You should then manually handle
34+
// the leftover parts.
35+
leavePartsOnError: false,
2536
});
2637

2738
parallelUploads3.on("httpUploadProgress", (progress) => {

Diff for: lib/lib-storage/src/Upload.spec.ts

+12
Original file line numberDiff line numberDiff line change
@@ -711,4 +711,16 @@ describe(Upload.name, () => {
711711
expect(error).toBeDefined();
712712
}
713713
});
714+
715+
it("should reject calling .done() more than once on an instance", async () => {
716+
const upload = new Upload({
717+
params,
718+
client: new S3({}),
719+
});
720+
721+
await upload.done();
722+
expect(() => upload.done()).rejects.toEqual(
723+
new Error("@aws-sdk/lib-storage: this instance of Upload has already executed .done(). Create a new instance.")
724+
);
725+
});
714726
});

Diff for: lib/lib-storage/src/Upload.ts

+145-102
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AbortMultipartUploadCommand,
23
CompletedPart,
34
CompleteMultipartUploadCommand,
45
CompleteMultipartUploadCommandOutput,
@@ -36,18 +37,18 @@ const MIN_PART_SIZE = 1024 * 1024 * 5;
3637

3738
export class Upload extends EventEmitter {
3839
/**
39-
* S3 multipart upload does not allow more than 10000 parts.
40+
* S3 multipart upload does not allow more than 10,000 parts.
4041
*/
41-
private MAX_PARTS = 10000;
42+
private MAX_PARTS = 10_000;
4243

4344
// Defaults.
44-
private queueSize = 4;
45-
private partSize = MIN_PART_SIZE;
46-
private leavePartsOnError = false;
47-
private tags: Tag[] = [];
45+
private readonly queueSize: number = 4;
46+
private readonly partSize = MIN_PART_SIZE;
47+
private readonly leavePartsOnError: boolean = false;
48+
private readonly tags: Tag[] = [];
4849

49-
private client: S3Client;
50-
private params: PutObjectCommandInput;
50+
private readonly client: S3Client;
51+
private readonly params: PutObjectCommandInput;
5152

5253
// used for reporting progress.
5354
private totalBytes?: number;
@@ -57,13 +58,19 @@ export class Upload extends EventEmitter {
5758
private abortController: IAbortController;
5859
private concurrentUploaders: Promise<void>[] = [];
5960
private createMultiPartPromise?: Promise<CreateMultipartUploadCommandOutput>;
61+
private abortMultipartUploadCommand: AbortMultipartUploadCommand | null = null;
6062

6163
private uploadedParts: CompletedPart[] = [];
62-
private uploadId?: string;
63-
uploadEvent?: string;
64+
private uploadEnqueuedPartsCount = 0;
65+
/**
66+
* Last UploadId if the upload was done with MultipartUpload and not PutObject.
67+
*/
68+
public uploadId?: string;
69+
public uploadEvent?: string;
6470

6571
private isMultiPart = true;
6672
private singleUploadResult?: CompleteMultipartUploadCommandOutput;
73+
private sent = false;
6774

6875
constructor(options: Options) {
6976
super();
@@ -94,6 +101,12 @@ export class Upload extends EventEmitter {
94101
}
95102

96103
public async done(): Promise<CompleteMultipartUploadCommandOutput> {
104+
if (this.sent) {
105+
throw new Error(
106+
"@aws-sdk/lib-storage: this instance of Upload has already executed .done(). Create a new instance."
107+
);
108+
}
109+
this.sent = true;
97110
return await Promise.race([this.__doMultipartUpload(), this.__abortTimeout(this.abortController.signal)]);
98111
}
99112

@@ -184,104 +197,64 @@ export class Upload extends EventEmitter {
184197
private async __createMultipartUpload(): Promise<CreateMultipartUploadCommandOutput> {
185198
if (!this.createMultiPartPromise) {
186199
const createCommandParams = { ...this.params, Body: undefined };
187-
this.createMultiPartPromise = this.client.send(new CreateMultipartUploadCommand(createCommandParams));
200+
this.createMultiPartPromise = this.client
201+
.send(new CreateMultipartUploadCommand(createCommandParams))
202+
.then((createMpuResponse) => {
203+
// We use the parameter Bucket/Key rather than the information from
204+
// createMultipartUpload response in case the Bucket is an access point arn.
205+
this.abortMultipartUploadCommand = new AbortMultipartUploadCommand({
206+
Bucket: this.params.Bucket,
207+
Key: this.params.Key,
208+
UploadId: createMpuResponse.UploadId,
209+
});
210+
return createMpuResponse;
211+
});
188212
}
189213
return this.createMultiPartPromise;
190214
}
191215

192216
private async __doConcurrentUpload(dataFeeder: AsyncGenerator<RawDataPart, void, undefined>): Promise<void> {
193217
for await (const dataPart of dataFeeder) {
194-
if (this.uploadedParts.length > this.MAX_PARTS) {
218+
if (this.uploadEnqueuedPartsCount > this.MAX_PARTS) {
195219
throw new Error(
196-
`Exceeded ${this.MAX_PARTS} as part of the upload to ${this.params.Key} and ${this.params.Bucket}.`
220+
`Exceeded ${this.MAX_PARTS} parts in multipart upload to Bucket: ${this.params.Bucket} Key: ${this.params.Key}.`
197221
);
198222
}
199223

200-
try {
201-
if (this.abortController.signal.aborted) {
202-
return;
203-
}
224+
if (this.abortController.signal.aborted) {
225+
return;
226+
}
204227

205-
// Use put instead of multi-part for one chunk uploads.
206-
if (dataPart.partNumber === 1 && dataPart.lastPart) {
207-
return await this.__uploadUsingPut(dataPart);
208-
}
228+
// Use put instead of multipart for one chunk uploads.
229+
if (dataPart.partNumber === 1 && dataPart.lastPart) {
230+
return await this.__uploadUsingPut(dataPart);
231+
}
209232

210-
if (!this.uploadId) {
211-
const { UploadId } = await this.__createMultipartUpload();
212-
this.uploadId = UploadId;
213-
if (this.abortController.signal.aborted) {
214-
return;
215-
}
233+
if (!this.uploadId) {
234+
const { UploadId } = await this.__createMultipartUpload();
235+
this.uploadId = UploadId;
236+
if (this.abortController.signal.aborted) {
237+
return;
216238
}
239+
}
217240

218-
const partSize: number = byteLength(dataPart.data) || 0;
219-
220-
const requestHandler = this.client.config.requestHandler;
221-
const eventEmitter: EventEmitter | null = requestHandler instanceof EventEmitter ? requestHandler : null;
222-
223-
let lastSeenBytes = 0;
224-
const uploadEventListener = (event: ProgressEvent, request: HttpRequest) => {
225-
const requestPartSize = Number(request.query["partNumber"]) || -1;
226-
227-
if (requestPartSize !== dataPart.partNumber) {
228-
// ignored, because the emitted event is not for this part.
229-
return;
230-
}
231-
232-
if (event.total && partSize) {
233-
this.bytesUploadedSoFar += event.loaded - lastSeenBytes;
234-
lastSeenBytes = event.loaded;
235-
}
236-
237-
this.__notifyProgress({
238-
loaded: this.bytesUploadedSoFar,
239-
total: this.totalBytes,
240-
part: dataPart.partNumber,
241-
Key: this.params.Key,
242-
Bucket: this.params.Bucket,
243-
});
244-
};
241+
const partSize: number = byteLength(dataPart.data) || 0;
245242

246-
if (eventEmitter !== null) {
247-
// The requestHandler is the xhr-http-handler.
248-
eventEmitter.on("xhr.upload.progress", uploadEventListener);
249-
}
243+
const requestHandler = this.client.config.requestHandler;
244+
const eventEmitter: EventEmitter | null = requestHandler instanceof EventEmitter ? requestHandler : null;
250245

251-
const partResult = await this.client.send(
252-
new UploadPartCommand({
253-
...this.params,
254-
UploadId: this.uploadId,
255-
Body: dataPart.data,
256-
PartNumber: dataPart.partNumber,
257-
})
258-
);
246+
let lastSeenBytes = 0;
247+
const uploadEventListener = (event: ProgressEvent, request: HttpRequest) => {
248+
const requestPartSize = Number(request.query["partNumber"]) || -1;
259249

260-
if (eventEmitter !== null) {
261-
eventEmitter.off("xhr.upload.progress", uploadEventListener);
262-
}
263-
264-
if (this.abortController.signal.aborted) {
250+
if (requestPartSize !== dataPart.partNumber) {
251+
// ignored, because the emitted event is not for this part.
265252
return;
266253
}
267254

268-
if (!partResult.ETag) {
269-
throw new Error(
270-
`Part ${dataPart.partNumber} is missing ETag in UploadPart response. Missing Bucket CORS configuration for ETag header?`
271-
);
272-
}
273-
274-
this.uploadedParts.push({
275-
PartNumber: dataPart.partNumber,
276-
ETag: partResult.ETag,
277-
...(partResult.ChecksumCRC32 && { ChecksumCRC32: partResult.ChecksumCRC32 }),
278-
...(partResult.ChecksumCRC32C && { ChecksumCRC32C: partResult.ChecksumCRC32C }),
279-
...(partResult.ChecksumSHA1 && { ChecksumSHA1: partResult.ChecksumSHA1 }),
280-
...(partResult.ChecksumSHA256 && { ChecksumSHA256: partResult.ChecksumSHA256 }),
281-
});
282-
283-
if (eventEmitter === null) {
284-
this.bytesUploadedSoFar += partSize;
255+
if (event.total && partSize) {
256+
this.bytesUploadedSoFar += event.loaded - lastSeenBytes;
257+
lastSeenBytes = event.loaded;
285258
}
286259

287260
this.__notifyProgress({
@@ -291,33 +264,89 @@ export class Upload extends EventEmitter {
291264
Key: this.params.Key,
292265
Bucket: this.params.Bucket,
293266
});
294-
} catch (e) {
295-
// Failed to create multi-part or put
296-
if (!this.uploadId) {
297-
throw e;
298-
}
299-
// on leavePartsOnError throw an error so users can deal with it themselves,
300-
// otherwise swallow the error.
301-
if (this.leavePartsOnError) {
302-
throw e;
303-
}
267+
};
268+
269+
if (eventEmitter !== null) {
270+
// The requestHandler is the xhr-http-handler.
271+
eventEmitter.on("xhr.upload.progress", uploadEventListener);
272+
}
273+
274+
this.uploadEnqueuedPartsCount += 1;
275+
276+
const partResult = await this.client.send(
277+
new UploadPartCommand({
278+
...this.params,
279+
UploadId: this.uploadId,
280+
Body: dataPart.data,
281+
PartNumber: dataPart.partNumber,
282+
})
283+
);
284+
285+
if (eventEmitter !== null) {
286+
eventEmitter.off("xhr.upload.progress", uploadEventListener);
304287
}
288+
289+
if (this.abortController.signal.aborted) {
290+
return;
291+
}
292+
293+
if (!partResult.ETag) {
294+
throw new Error(
295+
`Part ${dataPart.partNumber} is missing ETag in UploadPart response. Missing Bucket CORS configuration for ETag header?`
296+
);
297+
}
298+
299+
this.uploadedParts.push({
300+
PartNumber: dataPart.partNumber,
301+
ETag: partResult.ETag,
302+
...(partResult.ChecksumCRC32 && { ChecksumCRC32: partResult.ChecksumCRC32 }),
303+
...(partResult.ChecksumCRC32C && { ChecksumCRC32C: partResult.ChecksumCRC32C }),
304+
...(partResult.ChecksumSHA1 && { ChecksumSHA1: partResult.ChecksumSHA1 }),
305+
...(partResult.ChecksumSHA256 && { ChecksumSHA256: partResult.ChecksumSHA256 }),
306+
});
307+
308+
if (eventEmitter === null) {
309+
this.bytesUploadedSoFar += partSize;
310+
}
311+
312+
this.__notifyProgress({
313+
loaded: this.bytesUploadedSoFar,
314+
total: this.totalBytes,
315+
part: dataPart.partNumber,
316+
Key: this.params.Key,
317+
Bucket: this.params.Bucket,
318+
});
305319
}
306320
}
307321

308322
private async __doMultipartUpload(): Promise<CompleteMultipartUploadCommandOutput> {
309-
// Set up data input chunks.
310323
const dataFeeder = getChunk(this.params.Body, this.partSize);
324+
const concurrentUploaderFailures: Error[] = [];
311325

312-
// Create and start concurrent uploads.
313326
for (let index = 0; index < this.queueSize; index++) {
314-
const currentUpload = this.__doConcurrentUpload(dataFeeder);
327+
const currentUpload = this.__doConcurrentUpload(dataFeeder).catch((err) => {
328+
concurrentUploaderFailures.push(err);
329+
});
315330
this.concurrentUploaders.push(currentUpload);
316331
}
317332

318-
// Create and start concurrent uploads.
319333
await Promise.all(this.concurrentUploaders);
334+
if (concurrentUploaderFailures.length >= 1) {
335+
await this.markUploadAsAborted();
336+
/**
337+
* Previously, each promise in concurrentUploaders could potentially throw
338+
* and immediately return control to user code. However, we want to wait for
339+
* all uploaders to finish before calling AbortMultipartUpload to avoid
340+
* stranding uploaded parts.
341+
*
342+
* We throw only the first error to be consistent with prior behavior,
343+
* but may consider combining the errors into a report in the future.
344+
*/
345+
throw concurrentUploaderFailures[0];
346+
}
347+
320348
if (this.abortController.signal.aborted) {
349+
await this.markUploadAsAborted();
321350
throw Object.assign(new Error("Upload aborted."), { name: "AbortError" });
322351
}
323352

@@ -341,6 +370,8 @@ export class Upload extends EventEmitter {
341370
result = this.singleUploadResult!;
342371
}
343372

373+
this.abortMultipartUploadCommand = null;
374+
344375
// Add tags to the object after it's completed the upload.
345376
if (this.tags.length) {
346377
await this.client.send(
@@ -356,6 +387,18 @@ export class Upload extends EventEmitter {
356387
return result;
357388
}
358389

390+
/**
391+
* Abort the last multipart upload in progress
392+
* if we know the upload id, the user did not specify to leave the parts, and
393+
* we have a prepared AbortMultipartUpload command.
394+
*/
395+
private async markUploadAsAborted(): Promise<void> {
396+
if (this.uploadId && !this.leavePartsOnError && null !== this.abortMultipartUploadCommand) {
397+
await this.client.send(this.abortMultipartUploadCommand);
398+
this.abortMultipartUploadCommand = null;
399+
}
400+
}
401+
359402
private __notifyProgress(progress: Progress): void {
360403
if (this.uploadEvent) {
361404
this.emit(this.uploadEvent, progress);

0 commit comments

Comments
 (0)