|
@@ -50,7 +50,7 @@ class MultipartUploader {
|
|
|
// upload was created already. That also ensures that the sequencing is right
|
|
|
// (so the `OP` definitely happens if the upload is created).
|
|
|
//
|
|
|
- // This mostly exists to make `_abortUpload` work well: only sending the abort request if
|
|
|
+ // This mostly exists to make `#abortUpload` work well: only sending the abort request if
|
|
|
// the upload was already created, and if the createMultipartUpload request is still in flight,
|
|
|
// aborting it immediately after it finishes.
|
|
|
this.createdPromise = Promise.reject() // eslint-disable-line prefer-promise-reject-errors
|
|
@@ -58,8 +58,9 @@ class MultipartUploader {
|
|
|
this.partsInProgress = 0
|
|
|
this.chunks = null
|
|
|
this.chunkState = null
|
|
|
+ this.lockedCandidatesForBatch = []
|
|
|
|
|
|
- this._initChunks()
|
|
|
+ this.#initChunks()
|
|
|
|
|
|
this.createdPromise.catch(() => {}) // silence uncaught rejection warning
|
|
|
}
|
|
@@ -71,11 +72,11 @@ class MultipartUploader {
|
|
|
*
|
|
|
* @returns {boolean}
|
|
|
*/
|
|
|
- _aborted () {
|
|
|
+ #aborted () {
|
|
|
return this.abortController.signal.aborted
|
|
|
}
|
|
|
|
|
|
- _initChunks () {
|
|
|
+ #initChunks () {
|
|
|
const chunks = []
|
|
|
const desiredChunkSize = this.options.getChunkSize(this.file)
|
|
|
// at least 5MB per request, at most 10k requests
|
|
@@ -100,10 +101,10 @@ class MultipartUploader {
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
- _createUpload () {
|
|
|
+ #createUpload () {
|
|
|
this.createdPromise = Promise.resolve().then(() => this.options.createMultipartUpload())
|
|
|
return this.createdPromise.then((result) => {
|
|
|
- if (this._aborted()) throw createAbortError()
|
|
|
+ if (this.#aborted()) throw createAbortError()
|
|
|
|
|
|
const valid = typeof result === 'object' && result
|
|
|
&& typeof result.uploadId === 'string'
|
|
@@ -116,18 +117,19 @@ class MultipartUploader {
|
|
|
this.uploadId = result.uploadId
|
|
|
|
|
|
this.options.onStart(result)
|
|
|
- this._uploadParts()
|
|
|
+ this.#uploadParts()
|
|
|
}).catch((err) => {
|
|
|
- this._onError(err)
|
|
|
+ this.#onError(err)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- _resumeUpload () {
|
|
|
- return Promise.resolve().then(() => this.options.listParts({
|
|
|
- uploadId: this.uploadId,
|
|
|
- key: this.key,
|
|
|
- })).then((parts) => {
|
|
|
- if (this._aborted()) throw createAbortError()
|
|
|
+ async #resumeUpload () {
|
|
|
+ try {
|
|
|
+ const parts = await this.options.listParts({
|
|
|
+ uploadId: this.uploadId,
|
|
|
+ key: this.key,
|
|
|
+ })
|
|
|
+ if (this.#aborted()) throw createAbortError()
|
|
|
|
|
|
parts.forEach((part) => {
|
|
|
const i = part.PartNumber - 1
|
|
@@ -146,26 +148,40 @@ class MultipartUploader {
|
|
|
})
|
|
|
}
|
|
|
})
|
|
|
- this._uploadParts()
|
|
|
- }).catch((err) => {
|
|
|
- this._onError(err)
|
|
|
- })
|
|
|
+ this.#uploadParts()
|
|
|
+ } catch (err) {
|
|
|
+ this.#onError(err)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- _uploadParts () {
|
|
|
+ #uploadParts () {
|
|
|
if (this.isPaused) return
|
|
|
|
|
|
- const need = this.options.limit - this.partsInProgress
|
|
|
- if (need === 0) return
|
|
|
-
|
|
|
// All parts are uploaded.
|
|
|
if (this.chunkState.every((state) => state.done)) {
|
|
|
- this._completeUpload()
|
|
|
+ this.#completeUpload()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ // For a 100MB file, with the default min chunk size of 5MB and a limit of 10:
|
|
|
+ //
|
|
|
+ // Total 20 parts
|
|
|
+ // ---------
|
|
|
+ // Need 1 is 10
|
|
|
+ // Need 2 is 5
|
|
|
+ // Need 3 is 5
|
|
|
+ const need = this.options.limit - this.partsInProgress
|
|
|
+ const completeChunks = this.chunkState.filter((state) => state.done).length
|
|
|
+ const remainingChunks = this.chunks.length - completeChunks
|
|
|
+ let minNeeded = Math.ceil(this.options.limit / 2)
|
|
|
+ if (minNeeded > remainingChunks) {
|
|
|
+ minNeeded = remainingChunks
|
|
|
+ }
|
|
|
+ if (need < minNeeded) return
|
|
|
+
|
|
|
const candidates = []
|
|
|
for (let i = 0; i < this.chunkState.length; i++) {
|
|
|
+ if (this.lockedCandidatesForBatch.includes(i)) continue
|
|
|
const state = this.chunkState[i]
|
|
|
if (state.done || state.busy) continue
|
|
|
|
|
@@ -174,18 +190,22 @@ class MultipartUploader {
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- candidates.forEach((index) => {
|
|
|
- this._uploadPartRetryable(index).then(() => {
|
|
|
- // Continue uploading parts
|
|
|
- this._uploadParts()
|
|
|
- }, (err) => {
|
|
|
- this._onError(err)
|
|
|
+ if (candidates.length === 0) return
|
|
|
+
|
|
|
+ this.#prepareUploadParts(candidates).then((result) => {
|
|
|
+ candidates.forEach((index) => {
|
|
|
+ const partNumber = index + 1
|
|
|
+ const prePreparedPart = { url: result.presignedUrls[partNumber], headers: result.headers }
|
|
|
+ this.#uploadPartRetryable(index, prePreparedPart).then(() => {
|
|
|
+ this.#uploadParts()
|
|
|
+ }, (err) => {
|
|
|
+ this.#onError(err)
|
|
|
+ })
|
|
|
})
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- _retryable ({ before, attempt, after }) {
|
|
|
+ #retryable ({ before, attempt, after }) {
|
|
|
const { retryDelays } = this.options
|
|
|
const { signal } = this.abortController
|
|
|
|
|
@@ -201,7 +221,7 @@ class MultipartUploader {
|
|
|
}
|
|
|
|
|
|
const doAttempt = (retryAttempt) => attempt().catch((err) => {
|
|
|
- if (this._aborted()) throw createAbortError()
|
|
|
+ if (this.#aborted()) throw createAbortError()
|
|
|
|
|
|
if (shouldRetry(err) && retryAttempt < retryDelays.length) {
|
|
|
return delay(retryDelays[retryAttempt], { signal })
|
|
@@ -219,53 +239,62 @@ class MultipartUploader {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- _uploadPartRetryable (index) {
|
|
|
- return this._retryable({
|
|
|
+ async #prepareUploadParts (candidates) {
|
|
|
+ this.lockedCandidatesForBatch.push(...candidates)
|
|
|
+
|
|
|
+ const result = await this.options.prepareUploadParts({
|
|
|
+ key: this.key,
|
|
|
+ uploadId: this.uploadId,
|
|
|
+ partNumbers: candidates.map((index) => index + 1),
|
|
|
+ })
|
|
|
+
|
|
|
+ const valid = typeof result?.presignedUrls === 'object'
|
|
|
+ if (!valid) {
|
|
|
+ throw new TypeError(
|
|
|
+ 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.'
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return result
|
|
|
+ }
|
|
|
+
|
|
|
+ #uploadPartRetryable (index, prePreparedPart) {
|
|
|
+ return this.#retryable({
|
|
|
before: () => {
|
|
|
this.partsInProgress += 1
|
|
|
},
|
|
|
- attempt: () => this._uploadPart(index),
|
|
|
+ attempt: () => this.#uploadPart(index, prePreparedPart),
|
|
|
after: () => {
|
|
|
this.partsInProgress -= 1
|
|
|
},
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- _uploadPart (index) {
|
|
|
+ #uploadPart (index, prePreparedPart) {
|
|
|
const body = this.chunks[index]
|
|
|
this.chunkState[index].busy = true
|
|
|
|
|
|
- return Promise.resolve().then(() => this.options.prepareUploadPart({
|
|
|
- key: this.key,
|
|
|
- uploadId: this.uploadId,
|
|
|
- body,
|
|
|
- number: index + 1,
|
|
|
- })).then((result) => {
|
|
|
- const valid = typeof result === 'object' && result
|
|
|
- && typeof result.url === 'string'
|
|
|
- if (!valid) {
|
|
|
- throw new TypeError('AwsS3/Multipart: Got incorrect result from `prepareUploadPart()`, expected an object `{ url }`.')
|
|
|
- }
|
|
|
+ const valid = typeof prePreparedPart?.url === 'string'
|
|
|
+ if (!valid) {
|
|
|
+ throw new TypeError('AwsS3/Multipart: Got incorrect result for `prePreparedPart`, expected an object `{ url }`.')
|
|
|
+ }
|
|
|
|
|
|
- return result
|
|
|
- }).then(({ url, headers }) => {
|
|
|
- if (this._aborted()) {
|
|
|
- this.chunkState[index].busy = false
|
|
|
- throw createAbortError()
|
|
|
- }
|
|
|
+ const { url, headers } = prePreparedPart
|
|
|
+ if (this.#aborted()) {
|
|
|
+ this.chunkState[index].busy = false
|
|
|
+ throw createAbortError()
|
|
|
+ }
|
|
|
|
|
|
- return this._uploadPartBytes(index, url, headers)
|
|
|
- })
|
|
|
+ return this.#uploadPartBytes(index, url, headers)
|
|
|
}
|
|
|
|
|
|
- _onPartProgress (index, sent, total) {
|
|
|
+ #onPartProgress (index, sent, total) {
|
|
|
this.chunkState[index].uploaded = ensureInt(sent)
|
|
|
|
|
|
const totalUploaded = this.chunkState.reduce((n, c) => n + c.uploaded, 0)
|
|
|
this.options.onProgress(totalUploaded, this.file.size)
|
|
|
}
|
|
|
|
|
|
- _onPartComplete (index, etag) {
|
|
|
+ #onPartComplete (index, etag) {
|
|
|
this.chunkState[index].etag = etag
|
|
|
this.chunkState[index].done = true
|
|
|
|
|
@@ -278,7 +307,7 @@ class MultipartUploader {
|
|
|
this.options.onPartComplete(part)
|
|
|
}
|
|
|
|
|
|
- _uploadPartBytes (index, url, headers) {
|
|
|
+ #uploadPartBytes (index, url, headers) {
|
|
|
const body = this.chunks[index]
|
|
|
const { signal } = this.abortController
|
|
|
|
|
@@ -307,7 +336,7 @@ class MultipartUploader {
|
|
|
xhr.upload.addEventListener('progress', (ev) => {
|
|
|
if (!ev.lengthComputable) return
|
|
|
|
|
|
- this._onPartProgress(index, ev.loaded, ev.total)
|
|
|
+ this.#onPartProgress(index, ev.loaded, ev.total)
|
|
|
})
|
|
|
|
|
|
xhr.addEventListener('abort', (ev) => {
|
|
@@ -328,7 +357,7 @@ class MultipartUploader {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- this._onPartProgress(index, body.size, body.size)
|
|
|
+ this.#onPartProgress(index, body.size, body.size)
|
|
|
|
|
|
// NOTE This must be allowed by CORS.
|
|
|
const etag = ev.target.getResponseHeader('ETag')
|
|
@@ -337,7 +366,7 @@ class MultipartUploader {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- this._onPartComplete(index, etag)
|
|
|
+ this.#onPartComplete(index, etag)
|
|
|
defer.resolve()
|
|
|
})
|
|
|
|
|
@@ -355,22 +384,23 @@ class MultipartUploader {
|
|
|
return promise
|
|
|
}
|
|
|
|
|
|
- _completeUpload () {
|
|
|
+ async #completeUpload () {
|
|
|
// Parts may not have completed uploading in sorted order, if limit > 1.
|
|
|
this.parts.sort((a, b) => a.PartNumber - b.PartNumber)
|
|
|
|
|
|
- return Promise.resolve().then(() => this.options.completeMultipartUpload({
|
|
|
- key: this.key,
|
|
|
- uploadId: this.uploadId,
|
|
|
- parts: this.parts,
|
|
|
- })).then((result) => {
|
|
|
+ try {
|
|
|
+ const result = await this.options.completeMultipartUpload({
|
|
|
+ key: this.key,
|
|
|
+ uploadId: this.uploadId,
|
|
|
+ parts: this.parts,
|
|
|
+ })
|
|
|
this.options.onSuccess(result)
|
|
|
- }, (err) => {
|
|
|
- this._onError(err)
|
|
|
- })
|
|
|
+ } catch (err) {
|
|
|
+ this.#onError(err)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- _abortUpload () {
|
|
|
+ #abortUpload () {
|
|
|
this.abortController.abort()
|
|
|
|
|
|
this.createdPromise.then(() => {
|
|
@@ -383,7 +413,7 @@ class MultipartUploader {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- _onError (err) {
|
|
|
+ #onError (err) {
|
|
|
if (err && err.name === 'AbortError') {
|
|
|
return
|
|
|
}
|
|
@@ -394,9 +424,9 @@ class MultipartUploader {
|
|
|
start () {
|
|
|
this.isPaused = false
|
|
|
if (this.uploadId) {
|
|
|
- this._resumeUpload()
|
|
|
+ this.#resumeUpload()
|
|
|
} else {
|
|
|
- this._createUpload()
|
|
|
+ this.#createUpload()
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -413,7 +443,7 @@ class MultipartUploader {
|
|
|
|
|
|
if (!really) return this.pause()
|
|
|
|
|
|
- this._abortUpload()
|
|
|
+ this.#abortUpload()
|
|
|
}
|
|
|
}
|
|
|
|