From 5477362eaeca8a0f25630fb39d0c36358bc5f8f0 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Fri, 1 Mar 2024 15:08:48 -0500 Subject: [PATCH] fix: renterd memory leak, abort uploads, inline progress --- .changeset/few-chefs-love.md | 5 + .changeset/large-crews-play.md | 5 + .changeset/rich-lemons-speak.md | 5 + .../Uploads/UploadsStatsMenu/index.tsx | 6 +- .../contexts/filesDirectory/columns.tsx | 34 ++++--- .../contexts/filesDirectory/dataset.tsx | 12 ++- apps/renterd/contexts/filesFlat/columns.tsx | 38 +++++--- apps/renterd/contexts/filesFlat/dataset.tsx | 12 ++- .../renterd/contexts/filesManager/dataset.tsx | 23 ++--- .../renterd/contexts/filesManager/uploads.tsx | 93 ++++++++++++------- apps/renterd/contexts/uploads/index.tsx | 22 ++++- apps/renterd/lib/multipartUpload.spec.ts | 84 ++++++++++------- apps/renterd/lib/multipartUpload.ts | 43 +++++---- 13 files changed, 252 insertions(+), 130 deletions(-) create mode 100644 .changeset/few-chefs-love.md create mode 100644 .changeset/large-crews-play.md create mode 100644 .changeset/rich-lemons-speak.md diff --git a/.changeset/few-chefs-love.md b/.changeset/few-chefs-love.md new file mode 100644 index 000000000..768b0f031 --- /dev/null +++ b/.changeset/few-chefs-love.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Directory and global file explorers now show upload progress inline. diff --git a/.changeset/large-crews-play.md b/.changeset/large-crews-play.md new file mode 100644 index 000000000..9c106d320 --- /dev/null +++ b/.changeset/large-crews-play.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed a slow memory leak that became especially apparent during long running large uploads, memory usage is now minimal and stable over time. diff --git a/.changeset/rich-lemons-speak.md b/.changeset/rich-lemons-speak.md new file mode 100644 index 000000000..9b32be18e --- /dev/null +++ b/.changeset/rich-lemons-speak.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Uploads now support aborting the entire visible page of active uploads. diff --git a/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx b/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx index 50d7c560d..651b121c7 100644 --- a/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx +++ b/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx @@ -1,11 +1,13 @@ -import { PaginatorMarker } from '@siafoundation/design-system' +import { Button, PaginatorMarker } from '@siafoundation/design-system' import { useUploads } from '../../../contexts/uploads' export function UploadsStatsMenu() { - const { limit, pageCount, dataState, nextMarker, hasMore } = useUploads() + const { abortAll, limit, pageCount, dataState, nextMarker, hasMore } = + useUploads() return (
+ {pageCount > 0 && } - } if (name === '..') { return null @@ -187,9 +179,27 @@ export const columns: FilesTableColumn[] = [ label: 'health', contentClassName: 'justify-center', render: function HealthColumn({ data }) { - if (data.type === 'bucket') { + const { type, isUploading, loaded, size } = data + if (type === 'bucket') { return null } + if (isUploading) { + const displayPercent = ((loaded / size) * 100).toFixed(0) + '%' + return ( + +
+ + + + + {displayPercent} + +
+
+ ) + } return }, }, diff --git a/apps/renterd/contexts/filesDirectory/dataset.tsx b/apps/renterd/contexts/filesDirectory/dataset.tsx index 7976c8b5c..6b73c8335 100644 --- a/apps/renterd/contexts/filesDirectory/dataset.tsx +++ b/apps/renterd/contexts/filesDirectory/dataset.tsx @@ -56,11 +56,17 @@ export function useDataset() { }, }) - const d = useDatasetGeneric({ - objects: { + const objects = useMemo( + () => ({ isValidating: response.isValidating, data: response.data?.entries, - }, + }), + [response.isValidating, response.data?.entries] + ) + + const d = useDatasetGeneric({ + id: 'filesDirectory', + objects, }) return { diff --git a/apps/renterd/contexts/filesFlat/columns.tsx b/apps/renterd/contexts/filesFlat/columns.tsx index e1a43ac9c..160d7f9c5 100644 --- a/apps/renterd/contexts/filesFlat/columns.tsx +++ b/apps/renterd/contexts/filesFlat/columns.tsx @@ -1,11 +1,10 @@ +import { Button, Text, Tooltip, ValueNum } from '@siafoundation/design-system' import { - Button, - LoadingDots, - Text, - Tooltip, - ValueNum, -} from '@siafoundation/design-system' -import { Document16, Earth16, Locked16 } from '@siafoundation/react-icons' + Document16, + Earth16, + Locked16, + Upload16, +} from '@siafoundation/react-icons' import { humanBytes } from '@siafoundation/units' import { FileContextMenu } from '../../components/Files/FileContextMenu' import { DirectoryContextMenu } from '../../components/Files/DirectoryContextMenu' @@ -126,13 +125,10 @@ export const columns: FilesTableColumn[] = [ id: 'size', label: 'size', contentClassName: 'justify-end', - render: function SizeColumn({ data: { type, name, size, isUploading } }) { + render: function SizeColumn({ data: { type, name, size } }) { if (type === 'bucket') { return null } - if (isUploading) { - return - } if (name === '..') { return null @@ -155,9 +151,27 @@ export const columns: FilesTableColumn[] = [ label: 'health', contentClassName: 'justify-center', render: function HealthColumn({ data }) { - if (data.type === 'bucket') { + const { type, isUploading, loaded, size } = data + if (type === 'bucket') { return null } + if (isUploading) { + const displayPercent = ((loaded / size) * 100).toFixed(0) + '%' + return ( + +
+ + + + + {displayPercent} + +
+
+ ) + } return }, }, diff --git a/apps/renterd/contexts/filesFlat/dataset.tsx b/apps/renterd/contexts/filesFlat/dataset.tsx index d21a25c2f..979b2ae38 100644 --- a/apps/renterd/contexts/filesFlat/dataset.tsx +++ b/apps/renterd/contexts/filesFlat/dataset.tsx @@ -52,11 +52,17 @@ export function useDataset({ sortDirection, sortField }: Props) { }, }) - const d = useDatasetGeneric({ - objects: { + const objects = useMemo( + () => ({ isValidating: response.isValidating, data: response.data?.objects, - }, + }), + [response.isValidating, response.data] + ) + + const d = useDatasetGeneric({ + id: 'filesFlat', + objects, }) return { diff --git a/apps/renterd/contexts/filesManager/dataset.tsx b/apps/renterd/contexts/filesManager/dataset.tsx index c0b7a71c5..7a6fa6dc1 100644 --- a/apps/renterd/contexts/filesManager/dataset.tsx +++ b/apps/renterd/contexts/filesManager/dataset.tsx @@ -10,15 +10,17 @@ import { isDirectory, } from '../../lib/paths' import { useFilesManager } from '.' +import { useEffect } from 'react' type Props = { + id: string objects: { isValidating: boolean data?: ObjEntry[] } } -export function useDataset({ objects }: Props) { +export function useDataset({ id, objects }: Props) { const { activeBucket, activeBucketName, @@ -31,17 +33,10 @@ export function useDataset({ objects }: Props) { setActiveDirectory, } = useFilesManager() const { dataset: allContracts } = useContracts() - return useSWR( + const response = useSWR( objects.isValidating || buckets.isValidating ? null - : [ - objects.data, - uploadsList, - allContracts, - buckets.data, - activeBucketName, - activeDirectoryPath, - ], + : [id, activeBucketName, activeDirectoryPath], () => { const dataMap: Record = {} if (!activeBucket) { @@ -61,7 +56,7 @@ export function useDataset({ objects }: Props) { type: 'bucket', } }) - } else if (objects.data) { + } else if (objects.data || uploadsList.length) { objects.data?.forEach(({ name: key, size, health }) => { const path = join(activeBucketName, key) const name = getFilename(key) @@ -102,4 +97,10 @@ export function useDataset({ objects }: Props) { keepPreviousData: true, } ) + // refetch when the dependent data changes + useEffect(() => { + response.mutate() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objects.data, uploadsList, allContracts, buckets.data]) + return response } diff --git a/apps/renterd/contexts/filesManager/uploads.tsx b/apps/renterd/contexts/filesManager/uploads.tsx index dd88bc2b0..644060c4e 100644 --- a/apps/renterd/contexts/filesManager/uploads.tsx +++ b/apps/renterd/contexts/filesManager/uploads.tsx @@ -11,7 +11,7 @@ import { useMultipartUploadCreate, } from '@siafoundation/react-renterd' import { throttle } from '@technically/lodash' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ObjectUploadData, UploadsMap } from './types' import { FullPath, @@ -37,10 +37,10 @@ type Props = { export function useUploads({ activeDirectoryPath }: Props) { const buckets = useBuckets() const mutate = useMutate() - const apiWorkerUploadPart = useMultipartUploadPart() - const apiBusUploadComplete = useMultipartUploadComplete() - const apiBusUploadCreate = useMultipartUploadCreate() - const apiBusUploadAbort = useMultipartUploadAbort() + const workerUploadPart = useMultipartUploadPart() + const busUploadComplete = useMultipartUploadComplete() + const busUploadCreate = useMultipartUploadCreate() + const busUploadAbort = useMultipartUploadAbort() const [uploadsMap, setUploadsMap] = useState({}) const redundancy = useRedundancySettings({ config: { @@ -50,14 +50,6 @@ export function useUploads({ activeDirectoryPath }: Props) { }, }) - // Because checkAndStartUploads is called in closures/asynchronous callbacks, - // use a ref to ensure the latest version of the function is used. - const ref = useRef<{ - checkAndStartUploads: () => void - }>({ - checkAndStartUploads: () => null, - }) - const updateStatusToUploading = useCallback( ({ id }: { id: string }) => { setUploadsMap((map) => ({ @@ -119,10 +111,7 @@ export function useUploads({ activeDirectoryPath }: Props) { file: uploadFile, path: key, bucket: bucket.name, - apiWorkerUploadPart, - apiBusUploadComplete, - apiBusUploadCreate, - apiBusUploadAbort, + api: ref.current, partSize: getMultipartUploadPartSize( redundancy.data?.minShards || 1 ).toNumber(), @@ -132,11 +121,11 @@ export function useUploads({ activeDirectoryPath }: Props) { const uploadId = await multipartUpload.create() multipartUpload.setOnError((error) => { triggerErrorToast(error.message) - removeUpload(uploadId) + ref.current.removeUpload(uploadId) }) multipartUpload.setOnProgress( throttle((progress) => { - updateUploadProgress({ + ref.current.updateUploadProgress({ id: uploadId, loaded: progress.sent, size: progress.total, @@ -144,8 +133,8 @@ export function useUploads({ activeDirectoryPath }: Props) { }, 1000) ) multipartUpload.setOnComplete(async () => { - await mutate((key) => key.startsWith('/bus/objects')) - removeUpload(uploadId) + await ref.current.mutate((key) => key.startsWith('/bus/objects')) + ref.current.removeUpload(uploadId) setTimeout(() => { ref.current.checkAndStartUploads() }, 100) @@ -155,16 +144,7 @@ export function useUploads({ activeDirectoryPath }: Props) { multipartUpload, } }, - [ - apiBusUploadAbort, - apiBusUploadComplete, - apiBusUploadCreate, - apiWorkerUploadPart, - mutate, - updateUploadProgress, - removeUpload, - redundancy.data, - ] + [redundancy.data] ) const addUploadToQueue = useCallback( @@ -200,13 +180,13 @@ export function useUploads({ activeDirectoryPath }: Props) { createdAt: new Date().toISOString(), uploadAbort: async () => { await multipartUpload.abort() - removeUpload(uploadId) + ref.current.removeUpload(uploadId) }, type: 'file', }, })) }, - [setUploadsMap, createMultipartUpload, removeUpload] + [setUploadsMap, createMultipartUpload] ) const startMultipartUpload = useCallback( @@ -273,9 +253,52 @@ export function useUploads({ activeDirectoryPath }: Props) { [activeDirectoryPath, addUploadToQueue, buckets.data, uploadsMap] ) - ref.current = { + // Use a ref for functions that will be used in closures/asynchronous callbacks + // to ensure the latest version of the function is used. + const ref = useRef({ checkAndStartUploads, - } + workerUploadPart: workerUploadPart, + busUploadComplete: busUploadComplete, + busUploadCreate: busUploadCreate, + busUploadAbort: busUploadAbort, + removeUpload, + updateUploadProgress, + updateStatusToUploading, + mutate, + }) + + useEffect(() => { + ref.current = { + checkAndStartUploads, + busUploadAbort, + busUploadComplete, + busUploadCreate, + workerUploadPart, + mutate, + removeUpload, + updateUploadProgress, + updateStatusToUploading, + } + }, [ + checkAndStartUploads, + busUploadAbort, + busUploadComplete, + busUploadCreate, + workerUploadPart, + mutate, + removeUpload, + updateUploadProgress, + updateStatusToUploading, + ]) + + useEffect(() => { + const i = setInterval(() => { + ref.current.checkAndStartUploads() + }, 3_000) + return () => { + clearInterval(i) + } + }, []) const uploadsList: ObjectUploadData[] = useMemo( () => Object.entries(uploadsMap).map((u) => u[1] as ObjectUploadData), diff --git a/apps/renterd/contexts/uploads/index.tsx b/apps/renterd/contexts/uploads/index.tsx index 90bff6a2e..8b25748f5 100644 --- a/apps/renterd/contexts/uploads/index.tsx +++ b/apps/renterd/contexts/uploads/index.tsx @@ -8,7 +8,7 @@ import { useMultipartUploadAbort, useMultipartUploadListUploads, } from '@siafoundation/react-renterd' -import { createContext, useContext, useMemo } from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' import { columnsDefaultVisible, defaultSortField, sortOptions } from './types' import { columns } from './columns' import { join, getFilename } from '../../lib/paths' @@ -36,6 +36,25 @@ function useUploadsMain() { }, }) + const abortAll = useCallback(async () => { + return Promise.all( + response.data?.uploads?.map(async (upload) => { + const localUpload = uploadsMap[upload.uploadID] + if (localUpload) { + localUpload.uploadAbort?.() + } else { + await apiBusUploadAbort.post({ + payload: { + bucket: activeBucket?.name, + path: upload.path, + uploadID: upload.uploadID, + }, + }) + } + }) + ) + }, [response.data, apiBusUploadAbort, activeBucket, uploadsMap]) + const dataset: ObjectUploadData[] = useMemo(() => { return ( response.data?.uploads?.map((upload) => { @@ -110,6 +129,7 @@ function useUploadsMain() { ) return { + abortAll, dataState, limit, nextMarker: response.data?.nextUploadIDMarker, diff --git a/apps/renterd/lib/multipartUpload.spec.ts b/apps/renterd/lib/multipartUpload.spec.ts index 3e0a9a544..92434a293 100644 --- a/apps/renterd/lib/multipartUpload.spec.ts +++ b/apps/renterd/lib/multipartUpload.spec.ts @@ -47,7 +47,7 @@ describe('MultipartUpload', () => { [19, 20, 95], [20, 20, 100], ]) - expect(params.apiBusUploadComplete.post).toHaveBeenCalledWith({ + expect(params.api.busUploadComplete.post).toHaveBeenCalledWith({ payload: { bucket: 'test-bucket', parts: [ @@ -111,7 +111,7 @@ describe('MultipartUpload', () => { [19, 20, 95], [20, 20, 100], ]) - expect(params.apiBusUploadComplete.post).toHaveBeenCalledWith({ + expect(params.api.busUploadComplete.post).toHaveBeenCalledWith({ payload: { bucket: 'test-bucket', parts: [ @@ -141,14 +141,16 @@ describe('MultipartUpload', () => { const params = getMockedParams({ file: new File(['012456'], 'test.txt', { type: 'text/plain' }), partSize, - apiWorkerUploadPart: buildMockApiWorkerUploadPart({ - partSize, - failures: [ - { failCallIndex: 1, failPartIndex: 1 }, - { failCallIndex: 2, failPartIndex: 1 }, - { failCallIndex: 3, failPartIndex: 0 }, - ], - }), + api: { + workerUploadPart: buildMockApiWorkerUploadPart({ + partSize, + failures: [ + { failCallIndex: 1, failPartIndex: 1 }, + { failCallIndex: 2, failPartIndex: 1 }, + { failCallIndex: 3, failPartIndex: 0 }, + ], + }), + }, maxConcurrentParts: 1, }) const multipartUpload = new MultipartUpload(params) @@ -174,7 +176,7 @@ describe('MultipartUpload', () => { [5, 6, 83], // call 5 [6, 6, 100], ]) - expect(params.apiBusUploadComplete.post).toHaveBeenCalledWith({ + expect(params.api.busUploadComplete.post).toHaveBeenCalledWith({ payload: { bucket: 'test-bucket', parts: [ @@ -200,10 +202,14 @@ describe('MultipartUpload', () => { const params = getMockedParams({ file: new File(['012456'], 'test.txt', { type: 'text/plain' }), partSize, - apiWorkerUploadPart: buildMockApiWorkerUploadPart({ - partSize, - failures: [{ failCallIndex: 1, failPartIndex: 1, type: 'missingEtag' }], - }), + api: { + workerUploadPart: buildMockApiWorkerUploadPart({ + partSize, + failures: [ + { failCallIndex: 1, failPartIndex: 1, type: 'missingEtag' }, + ], + }), + }, maxConcurrentParts: 1, }) const multipartUpload = new MultipartUpload(params) @@ -222,7 +228,7 @@ describe('MultipartUpload', () => { [3, 6, 50], // call 1 [4, 6, 67], // fail ]) - expect(params.apiBusUploadComplete.post).not.toHaveBeenCalled() + expect(params.api.busUploadComplete.post).not.toHaveBeenCalled() expect(params.onComplete).not.toHaveBeenCalled() expect(params.onError).toHaveBeenCalledWith(expect.any(ErrorNoETag)) }) @@ -231,8 +237,11 @@ describe('MultipartUpload', () => { const params = getMockedParams() const multipartUpload = new MultipartUpload({ ...params, - apiBusUploadCreate: { - post: jest.fn(() => Promise.reject(new Error('Create failed'))), + api: { + ...params?.api, + busUploadCreate: { + post: jest.fn(() => Promise.reject(new Error('Create failed'))), + }, }, }) try { @@ -250,7 +259,7 @@ describe('MultipartUpload', () => { // allow the upload to get created and begin await delay(10) await multipartUpload.abort() - expect(params.apiBusUploadAbort.post).toHaveBeenCalledWith({ + expect(params.api.busUploadAbort.post).toHaveBeenCalledWith({ payload: { bucket: 'test-bucket', path: 'test-path', uploadID: '12345' }, }) expect(params.onComplete).not.toHaveBeenCalled() @@ -260,32 +269,41 @@ describe('MultipartUpload', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any function getMockedParams(params?: Partial>) { + const { + partSize: paramsPartSize, + file: paramsFile, + api: paramsApi, + ...paramsRest + } = params || {} const file = - params?.file || + paramsFile || new File(['0123456789'], 'test-file.txt', { type: 'text/plain' }) - const partSize = params?.partSize || 1 + const partSize = paramsPartSize || 1 return { bucket: 'test-bucket', path: 'test-path', partSize, maxConcurrentParts: 1, file, - apiWorkerUploadPart: buildMockApiWorkerUploadPart({ partSize }), - apiBusUploadComplete: { post: jest.fn() }, - apiBusUploadCreate: { - post: jest.fn(() => - Promise.resolve({ - status: 201, - data: { uploadID: '12345' }, - headers: { ETag: 'etag' }, - }) - ), + api: { + workerUploadPart: buildMockApiWorkerUploadPart({ partSize }), + busUploadComplete: { post: jest.fn() }, + busUploadCreate: { + post: jest.fn(() => + Promise.resolve({ + status: 201, + data: { uploadID: '12345' }, + headers: { ETag: 'etag' }, + }) + ), + }, + busUploadAbort: { post: jest.fn() }, + ...paramsApi, }, - apiBusUploadAbort: { post: jest.fn() }, onProgress: jest.fn(), onError: jest.fn(), onComplete: jest.fn(), - ...params, + ...paramsRest, } } diff --git a/apps/renterd/lib/multipartUpload.ts b/apps/renterd/lib/multipartUpload.ts index 9c7879455..84f63cf02 100644 --- a/apps/renterd/lib/multipartUpload.ts +++ b/apps/renterd/lib/multipartUpload.ts @@ -16,10 +16,12 @@ export type MultipartParams = { bucket: string path: string file: File - apiWorkerUploadPart: ApiWorkerUploadPart - apiBusUploadComplete: ApiBusUploadComplete - apiBusUploadCreate: ApiBusUploadCreate - apiBusUploadAbort: ApiBusUploadAbort + api: { + workerUploadPart: ApiWorkerUploadPart + busUploadComplete: ApiBusUploadComplete + busUploadCreate: ApiBusUploadCreate + busUploadAbort: ApiBusUploadAbort + } partSize?: number maxConcurrentParts?: number onProgress?: (event: { @@ -43,10 +45,12 @@ export class MultipartUpload { #file: File #partSize: number #maxConcurrentParts: number - #apiWorkerUploadPart: ApiWorkerUploadPart - #apiBusUploadComplete: ApiBusUploadComplete - #apiBusUploadCreate: ApiBusUploadCreate - #apiBusUploadAbort: ApiBusUploadAbort + #api: { + workerUploadPart: ApiWorkerUploadPart + busUploadComplete: ApiBusUploadComplete + busUploadCreate: ApiBusUploadCreate + busUploadAbort: ApiBusUploadAbort + } #onProgress: (progress: { sent: number total: number @@ -75,10 +79,7 @@ export class MultipartUpload { this.#partSize = options.partSize || 1024 * 1024 * 5 this.#maxConcurrentParts = Math.min(options.maxConcurrentParts || 5, 15) this.#file = options.file - this.#apiWorkerUploadPart = options.apiWorkerUploadPart - this.#apiBusUploadAbort = options.apiBusUploadAbort - this.#apiBusUploadComplete = options.apiBusUploadComplete - this.#apiBusUploadCreate = options.apiBusUploadCreate + this.#api = options.api this.#onProgress = options.onProgress || (() => null) this.#onError = options.onError || (() => null) this.#onComplete = options.onComplete || (() => null) @@ -98,7 +99,7 @@ export class MultipartUpload { generateKey: true, path: this.#path, } - const response = await this.#apiBusUploadCreate.post({ + const response = await this.#api.busUploadCreate.post({ payload: createPayload, }) @@ -129,7 +130,7 @@ export class MultipartUpload { }) try { - await this.#apiBusUploadAbort.post({ + await this.#api.busUploadAbort.post({ payload: { bucket: this.#bucket, path: this.#path, @@ -139,7 +140,7 @@ export class MultipartUpload { } catch (e) { triggerErrorToast(e.message) } - this.#resolve() + this.#resolve?.() } public setOnProgress( @@ -207,10 +208,15 @@ export class MultipartUpload { this.#onError(error) return } + + // TODO: catch 400 errors and abort the upload, 400 means that the multipart upload does not exist anymore. + // Additionally renterd itself should abort these active uploads. + + // Besides the above, allow network errors to retry... this.#pendingPartNumbers.push(partNumber) await this.#waitToRetry() } - // try again even after a part errors + // try again after a part error, but not other specific errors this.#sendNext() } @@ -233,7 +239,7 @@ export class MultipartUpload { uploadID: this.#uploadId, parts: this.#uploadedParts.sort((a, b) => a.partNumber - b.partNumber), } - await this.#apiBusUploadComplete.post({ + await this.#api.busUploadComplete.post({ payload: payload, }) this.#onComplete() @@ -272,7 +278,7 @@ export class MultipartUpload { this.#activeConnections[partNumber] = controller afterConnectionIsAdded() try { - const response = await this.#apiWorkerUploadPart.put({ + const response = await this.#api.workerUploadPart.put({ params: { key: this.#path.slice(1), bucket: this.#bucket, @@ -310,6 +316,7 @@ export class MultipartUpload { this.#uploadedParts.push(uploadedPart) } finally { + this.#activeConnections[partNumber] = null delete this.#activeConnections[partNumber] } }