diff --git a/src/app/tasks/types.ts b/src/app/tasks/types.ts index b213d66f5..3565597c5 100644 --- a/src/app/tasks/types.ts +++ b/src/app/tasks/types.ts @@ -40,6 +40,17 @@ export enum TaskEvent { TaskError = 'task-error', } +export interface FailedUploadItem { + name: string; + type?: string; + size: number; + modifiedAt: number; + isFolder: boolean; + isRetryable: boolean; + isRetrying: boolean; + retry: () => Promise; +} + export interface BaseTask { id: string; relatedTaskId?: string; @@ -51,6 +62,8 @@ export interface BaseTask { downloadedProgress?: number; subtitle?: string; stop?: () => Promise; + failedItems?: FailedUploadItem[]; + retry?: () => Promise; } export interface CreateFolderTask extends BaseTask { diff --git a/src/app/upload/Upload.test.ts b/src/app/upload/Upload.test.ts index 34d5904b4..9c16de45c 100644 --- a/src/app/upload/Upload.test.ts +++ b/src/app/upload/Upload.test.ts @@ -9,6 +9,7 @@ import errorService from 'services/error.service'; import referralService from 'services/referral.service'; import { AppError } from '@internxt/sdk'; import { HTTP_STATUS_CODES } from 'app/core/constants'; +import { MAX_UPLOAD_ATTEMPTS } from 'app/network/networkConstants'; import { getCreatedFolder, getFileToUpload, @@ -57,6 +58,8 @@ const createUpload = ( callbacks: Partial[0]> = {}, ) => new Upload({ destinationFolderUuid: DESTINATION_UUID, roots, files, uploadRepository, ...callbacks }); +const uploadedNames = () => vi.mocked(uploadFile).mock.calls.map(([file]) => file.name); + const tasksOf = (action: TaskType) => tasksService.getTasks().filter((task) => task.action === action); const failUploadsOf = (failingNames: string[], error: unknown = new Error('Server error')) => @@ -65,13 +68,16 @@ const failUploadsOf = (failingNames: string[], error: unknown = new Error('Serve return { uuid: `${file.name}-uuid` }; }); +const uploadSucceeds = () => + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ uuid: `${file.name}-uuid` })); + describe('Uploading what the user picked', () => { beforeEach(() => { vi.clearAllMocks(); tasksService.clearTasks(); vi.spyOn(errorService, 'reportError').mockReturnValue(); vi.mocked(newStorageService.deleteFolderByUuid).mockResolvedValue(undefined as never); - (uploadFile as Mock).mockImplementation(async (file: { name: string }) => ({ uuid: `${file.name}-uuid` })); + uploadSucceeds(); fakeFolderCreationTaking(); }); @@ -115,6 +121,12 @@ describe('Uploading what the user picked', () => { expect(onItemUploaded).toHaveBeenCalledWith(expect.objectContaining({ name: 'report' }), DESTINATION_UUID, false); }); + test('When a folder is uploaded with everything in it, then it is counted as a folder upload', async () => { + await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []).run(); + + expect(referralService.trackFolderUpload).toHaveBeenCalledOnce(); + }); + test('When reporting an uploaded item fails, then the upload still finishes and each task succeeds', async () => { const onItemUploaded = vi.fn(() => { throw new Error('Listener error'); @@ -139,10 +151,7 @@ describe('Uploading what the user picked', () => { }); test('When a file inside a folder fails, then the folder still succeeds with what could be uploaded', async () => { - (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { - if (file.name === 'broken') throw new Error('Server error'); - return { uuid: `${file.name}-uuid` }; - }); + failUploadsOf(['broken']); const upload = createUpload( [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })], [], @@ -274,6 +283,217 @@ describe('Uploading what the user picked', () => { expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid'); expect(uploadFile).not.toHaveBeenCalled(); }); +}); + +describe('Retrying what failed', () => { + beforeEach(() => { + vi.clearAllMocks(); + tasksService.clearTasks(); + vi.spyOn(errorService, 'reportError').mockReturnValue(); + vi.mocked(newStorageService.deleteFolderByUuid).mockResolvedValue(undefined as never); + fakeFolderCreationTaking(); + }); + + test('When files inside a folder fail, then they are listed on the folder task', async () => { + failUploadsOf(['broken']); + + await createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })], + [], + ).run(); + + expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([ + expect.objectContaining({ name: 'broken', type: 'png', isFolder: false, isRetryable: true, isRetrying: false }), + ]); + }); + + test('When a folder and a file both fail, then each task lists only its own failed items', async () => { + failUploadsOf(['broken', 'report']); + + await createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], + [getFileToUpload('report.pdf')], + ).run(); + + expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([expect.objectContaining({ name: 'broken' })]); + expect(tasksOf(TaskType.UploadFile)[0].failedItems).toEqual([expect.objectContaining({ name: 'report' })]); + }); + + test('When the user retries a folder, then its failed files are uploaded again into it and the folder ends with nothing failed', async () => { + failUploadsOf(['broken']); + const upload = createUpload( + [getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png'), getFileToUpload('beach.png')] })], + [], + ); + await upload.run(); + uploadSucceeds(); + + await tasksOf(TaskType.UploadFolder)[0].retry?.(); + + const [folderTask] = tasksOf(TaskType.UploadFolder); + expect(uploadFile).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'broken', parentFolderId: 'Photos-uuid' }), + expect.any(Function), + expect.any(Object), + expect.any(Object), + ); + expect(folderTask).toEqual(expect.objectContaining({ status: TaskStatus.Success, progress: 1, failedItems: [] })); + }); + + test('When the user cancels a folder while it is being retried, then the retry finishes only once the folder is removed', async () => { + failUploadsOf(['broken']); + const upload = createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], []); + await upload.run(); + vi.mocked(uploadFile).mockClear(); + fakeUploadsTaking(500); + let finishRemoval: () => void = () => undefined; + vi.mocked(newStorageService.deleteFolderByUuid).mockReturnValue( + new Promise((resolve) => (finishRemoval = resolve)) as never, + ); + const onRetryFinished = vi.fn(); + const [folderTask] = tasksOf(TaskType.UploadFolder); + + const retrying = folderTask.retry?.().then(onRetryFinished); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + await tasksService.cancelTask(folderTask.id); + await vi.waitFor(() => expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith('Photos-uuid')); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(onRetryFinished).not.toHaveBeenCalled(); + finishRemoval(); + await retrying; + expect(onRetryFinished).toHaveBeenCalled(); + }); + + test('When the user retries one failed file, then only that file is uploaded again', async () => { + failUploadsOf(['retried', 'still-failing']); + await createUpload( + [ + getFolderToUpload('Photos', { + childrenFiles: [getFileToUpload('retried.png'), getFileToUpload('still-failing.png')], + }), + ], + [], + ).run(); + uploadSucceeds(); + vi.mocked(uploadFile).mockClear(); + const [folderTask] = tasksOf(TaskType.UploadFolder); + const retriedFailure = folderTask.failedItems?.find(({ name }) => name === 'retried'); + + await retriedFailure?.retry(); + + expect(uploadedNames()).toEqual(['retried']); + expect(tasksService.findTask(folderTask.id)?.failedItems).toEqual([ + expect.objectContaining({ name: 'still-failing' }), + ]); + }); + + test('When the user retries everything, then the files that retrying cannot fix are not uploaded again', async () => { + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + if (file.name === 'over-plan-limit') throw new AppError('Plan limit reached', HTTP_STATUS_CODES.PAYMENT_REQUIRED); + if (file.name === 'broken') throw new Error('Server error'); + return { uuid: `${file.name}-uuid` }; + }); + await createUpload( + [ + getFolderToUpload('Photos', { + childrenFiles: [getFileToUpload('over-plan-limit.png'), getFileToUpload('broken.png')], + }), + ], + [], + ).run(); + uploadSucceeds(); + vi.mocked(uploadFile).mockClear(); + + await tasksOf(TaskType.UploadFolder)[0].retry?.(); + + expect(uploadedNames()).toEqual(['broken']); + expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([ + expect.objectContaining({ name: 'over-plan-limit', isRetrying: false }), + ]); + }); + + test('When the user retries while a retry of the same task is still running, then nothing else is uploaded', async () => { + failUploadsOf(['retrying', 'waiting']); + await createUpload( + [ + getFolderToUpload('Photos', { + childrenFiles: [getFileToUpload('retrying.png'), getFileToUpload('waiting.png')], + }), + ], + [], + ).run(); + fakeUploadsTaking(100); + vi.mocked(uploadFile).mockClear(); + const [folderTask] = tasksOf(TaskType.UploadFolder); + const retryingFailure = folderTask.failedItems?.find(({ name }) => name === 'retrying'); + + const retryingOne = retryingFailure?.retry(); + const retryingEverything = tasksService.findTask(folderTask.id)?.retry?.(); + await Promise.all([retryingOne, retryingEverything]); + + expect(uploadedNames()).toEqual(['retrying']); + expect(tasksService.findTask(folderTask.id)).toEqual( + expect.objectContaining({ + status: TaskStatus.Success, + failedItems: [expect.objectContaining({ name: 'waiting' })], + }), + ); + }); + + test('When a folder is only fully uploaded after a retry, then it is counted as a folder upload', async () => { + failUploadsOf(['report']); + await createUpload([getFolderToUpload('Docs', { childrenFiles: [getFileToUpload('report.pdf')] })], []).run(); + uploadSucceeds(); + + await tasksOf(TaskType.UploadFolder)[0].retry?.(); + + expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([]); + expect(referralService.trackFolderUpload).toHaveBeenCalledOnce(); + }); + + test('When a folder that could not be created is retried, then it is created with all its content', async () => { + vi.mocked(folderService.createFolderByUuid).mockImplementation( + () => [Promise.reject(new Error('Server error')), { cancel: vi.fn() }] as never, + ); + await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] })], []).run(); + fakeFolderCreationTaking(); + + await tasksOf(TaskType.UploadFolder)[0].retry?.(); + + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ name: 'beach', parentFolderId: 'Photos-uuid' }), + expect.any(Function), + expect.any(Object), + expect.any(Object), + ); + expect(tasksOf(TaskType.UploadFolder)[0]).toEqual(expect.objectContaining({ status: TaskStatus.Success })); + }); + + test('When a failed file is retried, then its own task succeeds', async () => { + failUploadsOf(['report']); + await createUpload([], [getFileToUpload('report.pdf')]).run(); + uploadSucceeds(); + + await tasksOf(TaskType.UploadFile)[0].retry?.(); + + expect(tasksOf(TaskType.UploadFile)[0]).toEqual( + expect.objectContaining({ status: TaskStatus.Success, subtitle: undefined, failedItems: [] }), + ); + }); + + test('When the storage was full and the user retries after freeing space, then the files upload', async () => { + failUploadsOf(['report', 'invoice'], new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED)); + await createUpload( + [getFolderToUpload('Docs', { childrenFiles: [getFileToUpload('report.pdf'), getFileToUpload('invoice.pdf')] })], + [], + ).run(); + uploadSucceeds(); + + await tasksOf(TaskType.UploadFolder)[0].retry?.(); + + expect(tasksOf(TaskType.UploadFolder)[0].failedItems).toEqual([]); + }); test('When the storage fills up while uploading, then the user is told once, however many items fail', async () => { const onStorageFull = vi.fn(); @@ -283,4 +503,144 @@ describe('Uploading what the user picked', () => { expect(onStorageFull).toHaveBeenCalledOnce(); }); + + test('When a retry also finds the storage full, then the user is told again', async () => { + const onStorageFull = vi.fn(); + failUploadsOf(['report'], new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED)); + await createUpload([], [getFileToUpload('report.pdf')], { onStorageFull }).run(); + + await tasksOf(TaskType.UploadFile)[0].retry?.(); + + expect(onStorageFull).toHaveBeenCalledTimes(2); + }); + + test('When a cancelled folder is retried, then nothing is uploaded again', async () => { + failUploadsOf(['broken']); + await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], []).run(); + const [folderTask] = tasksOf(TaskType.UploadFolder); + await tasksService.cancelTask(folderTask.id); + vi.mocked(uploadFile).mockClear(); + + await folderTask.retry?.(); + + expect(uploadFile).not.toHaveBeenCalled(); + expect(tasksService.findTask(folderTask.id)).toEqual( + expect.objectContaining({ + status: TaskStatus.Cancelled, + failedItems: [expect.objectContaining({ name: 'broken', isRetrying: false })], + }), + ); + }); + + test('When the user frees space and retries while another file is still uploading, then the retried file uploads', async () => { + let isStorageFull = true; + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + if (file.name === 'video') await new Promise((resolve) => setTimeout(resolve, 500)); + else if (isStorageFull) throw new AppError('No space left', HTTP_STATUS_CODES.MAX_SPACE_USED); + return { uuid: `${file.name}-uuid` }; + }); + const upload = createUpload([], [getFileToUpload('report.pdf'), getFileToUpload('video.mp4')]); + const running = upload.run(); + const [reportTask, videoTask] = tasksOf(TaskType.UploadFile); + await vi.waitFor(() => expect(tasksService.findTask(reportTask.id)?.status).toBe(TaskStatus.Error)); + isStorageFull = false; + + await reportTask.retry?.(); + + expect(tasksService.findTask(reportTask.id)?.status).toBe(TaskStatus.Success); + expect(tasksService.findTask(videoTask.id)?.status).toBe(TaskStatus.InProcess); + await running; + }); + + test('When the failed files of two folders are retried, then each folder finishes when its own files do', async () => { + failUploadsOf(['broken', 'movie']); + await createUpload( + [ + getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] }), + getFolderToUpload('Videos', { childrenFiles: [getFileToUpload('movie.mp4')] }), + ], + [], + ).run(); + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + if (file.name === 'movie') await new Promise((resolve) => setTimeout(resolve, 500)); + return { uuid: `${file.name}-uuid` }; + }); + const [photosTask, videosTask] = tasksOf(TaskType.UploadFolder); + + const retryingVideos = videosTask.retry?.(); + await photosTask.retry?.(); + + expect(tasksService.findTask(photosTask.id)?.status).toBe(TaskStatus.Success); + expect(tasksService.findTask(videosTask.id)?.status).toBe(TaskStatus.InProcess); + await retryingVideos; + expect(tasksService.findTask(videosTask.id)?.status).toBe(TaskStatus.Success); + }); + + test('When a folder is retried while the rest of the upload is still running, then it only finishes once its retry does', async () => { + let failedPhotosCreations = 0; + vi.mocked(folderService.createFolderByUuid).mockImplementation((_parentUuid, name) => { + const shouldFail = name === 'Photos' && failedPhotosCreations < MAX_UPLOAD_ATTEMPTS; + if (shouldFail) failedPhotosCreations++; + const creation = shouldFail ? Promise.reject(new Error('Server error')) : Promise.resolve(getCreatedFolder(name)); + return [creation, { cancel: vi.fn() }] as never; + }); + (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { + const uploadTimeMs = file.name === 'movie' ? 300 : 600; + await new Promise((resolve) => setTimeout(resolve, uploadTimeMs)); + return { uuid: `${file.name}-uuid` }; + }); + const upload = createUpload( + [ + getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('beach.png')] }), + getFolderToUpload('Videos', { childrenFiles: [getFileToUpload('movie.mp4')] }), + ], + [], + ); + const running = upload.run(); + const [photosTask, videosTask] = tasksOf(TaskType.UploadFolder); + await vi.waitFor(() => expect(tasksService.findTask(photosTask.id)?.status).toBe(TaskStatus.Error)); + + const retryingPhotos = photosTask.retry?.(); + await running; + + expect(tasksService.findTask(videosTask.id)?.status).toBe(TaskStatus.Success); + expect(tasksService.findTask(photosTask.id)?.status).toBe(TaskStatus.InProcess); + expect(referralService.trackFolderUpload).toHaveBeenCalledOnce(); + await retryingPhotos; + expect(tasksService.findTask(photosTask.id)?.status).toBe(TaskStatus.Success); + }); + + test('When the user cancels a folder while its failed files are retried, then they are no longer shown as retrying', async () => { + failUploadsOf(['broken']); + await createUpload([getFolderToUpload('Photos', { childrenFiles: [getFileToUpload('broken.png')] })], []).run(); + fakeUploadsTaking(500); + const [folderTask] = tasksOf(TaskType.UploadFolder); + + const retrying = folderTask.retry?.(); + await vi.waitFor(() => + expect(tasksService.findTask(folderTask.id)?.failedItems).toEqual([ + expect.objectContaining({ name: 'broken', isRetrying: true }), + ]), + ); + await tasksService.cancelTask(folderTask.id); + await retrying; + + expect(tasksService.findTask(folderTask.id)?.failedItems).toEqual([]); + }); + + test('When the user cancels a failed file while it is retried, then it is no longer shown as retrying', async () => { + failUploadsOf(['report']); + await createUpload([], [getFileToUpload('report.pdf')]).run(); + fakeUploadsTaking(500); + const [fileTask] = tasksOf(TaskType.UploadFile); + + const retrying = fileTask.retry?.(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(2)); + await tasksService.cancelTask(fileTask.id); + await retrying; + + expect(tasksService.findTask(fileTask.id)).toEqual( + expect.objectContaining({ status: TaskStatus.Cancelled, failedItems: [] }), + ); + }); }); diff --git a/src/app/upload/Upload.ts b/src/app/upload/Upload.ts index 73a267586..7370ed608 100644 --- a/src/app/upload/Upload.ts +++ b/src/app/upload/Upload.ts @@ -24,10 +24,11 @@ import { import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; import { IRoot } from 'app/store/slices/storage/types'; import tasksService from 'app/tasks/services/tasks.service'; -import { TaskStatus, TaskType, UploadFileTask, UploadFolderTask } from 'app/tasks/types'; +import { FailedUploadItem, TaskStatus, TaskType, UploadFileTask, UploadFolderTask } from 'app/tasks/types'; import { errorMerge, folderRemovalFailedMerge } from 'app/tasks/upload/uploadTaskErrors'; import errorService from 'services/error.service'; import referralService from 'services/referral.service'; +import { toFailedUploadItem } from './failedUploadItem'; interface UploadProps { destinationFolderUuid: string; @@ -45,7 +46,7 @@ interface RootFolderUpload { totalItems: number; uploadedItems: number; isCancelled: boolean; - hasFailedItems: boolean; + activeRuns: number; createdFolder?: DriveFolderData; } @@ -61,35 +62,68 @@ const isTaskStopped = (taskId: string): boolean => { export class Upload { private readonly props: UploadProps; - private readonly manager: UploadManager; private readonly items: UploadItem[]; private readonly taskIdByItem = new Map(); private readonly rootFolders = new Map(); private readonly cancelledRootFolderDeletions: Promise[] = []; + private readonly failuresByTopLevelItem = new Map>(); + private readonly activeManagers = new Set(); constructor(props: UploadProps) { this.props = props; this.items = createItemsToUpload(props); - this.manager = new UploadManager({ - ownerUserAuthenticationData: props.ownerUserAuthenticationData, - uploadRepository: props.uploadRepository, - getPauseKey: (item) => this.getTaskIdOf(getTopLevelItem(item)) as string, - events: this.createManagerEvents(), - }); for (const item of this.items) { if (item.kind === UploadItemKind.Folder) this.trackRootFolder(item); else this.trackFile(item); + this.failuresByTopLevelItem.set(item, new Map()); } } - async run(): Promise { - const result = await this.manager.run(this.items); + run(): Promise { + return this.uploadAndFinish(this.items, this.items); + } + + /** + * A root folder can be in more than one run at a time (the upload and a retry of what failed in it), so it + * only finishes when its last run does. + */ + private async uploadAndFinish(items: UploadItem[], topLevelItems: UploadItem[]): Promise { + const rootFoldersInRun = topLevelItems.filter((item) => this.rootFolders.has(item)) as UploadFolderItem[]; + rootFoldersInRun.forEach((item) => this.getRootFolder(item).activeRuns++); + + const manager = this.createManager(); + this.activeManagers.add(manager); + const result = await manager.run(items); + this.activeManagers.delete(manager); await Promise.all(this.cancelledRootFolderDeletions); - this.rootFolders.forEach((rootFolder, item) => this.finishRootFolder(item as UploadFolderItem, rootFolder)); + + for (const item of rootFoldersInRun) { + const rootFolder = this.getRootFolder(item); + rootFolder.activeRuns--; + if (rootFolder.activeRuns === 0) this.finishRootFolder(item, rootFolder); + } + return result; } + private getRootFolder(item: UploadItem): RootFolderUpload { + return this.rootFolders.get(item) as RootFolderUpload; + } + + private createManager(): UploadManager { + return new UploadManager({ + ownerUserAuthenticationData: this.props.ownerUserAuthenticationData, + uploadRepository: this.props.uploadRepository, + getPauseKey: (item) => this.getTaskIdOf(getTopLevelItem(item)) as string, + events: this.createManagerEvents(), + }); + } + + private cancelItem(item: UploadItem): void { + this.activeManagers.forEach((manager) => manager.cancelItem(item)); + } + private createManagerEvents(): UploadManagerEvents { return { onItemStarted: (item) => this.onItemStarted(item), @@ -114,6 +148,7 @@ export class Upload { showNotification: true, cancellable: true, stop: () => Promise.resolve(this.cancelRootFolder(item)), + retry: () => this.retry(item), }); this.taskIdByItem.set(item, taskId); @@ -122,13 +157,11 @@ export class Upload { totalItems: countItemsIn(item.folder), uploadedItems: 0, isCancelled: false, - hasFailedItems: false, + activeRuns: 0, }); } private trackFile(item: UploadFileItem): void { - const { ownerUserAuthenticationData, destinationFolderUuid } = this.props; - const taskId = tasksService.create({ action: TaskType.UploadFile, item: { uploadFile: item.file.content, parentFolderId: item.file.parentFolderId }, @@ -137,12 +170,8 @@ export class Upload { isFileNameValidated: true, showNotification: true, cancellable: true, - sharedItemAuthenticationData: ownerUserAuthenticationData && { - ownerUserAuthenticationData, - isDeepFolder: false, - currentFolderId: destinationFolderUuid, - }, - stop: () => Promise.resolve(this.manager.cancelItem(item)), + stop: () => Promise.resolve(this.cancelItem(item)), + retry: () => this.retry(item), }); this.taskIdByItem.set(item, taskId); @@ -178,6 +207,7 @@ export class Upload { const isFileWithItsOwnTask = !!taskId && !isFolderItem(item); if (isFileWithItsOwnTask) this.finishFileTask(taskId, { fileUUID: uploadedItem.uuid }); else this.countUploadedInRootFolder(item, uploadedItem); + this.clearFailure(item); const isFile = item.kind === UploadItemKind.File; this.props.onItemUploaded?.(uploadedItem, getParentFolderUuid(item), !isFile); @@ -190,7 +220,7 @@ export class Upload { private countUploadedInRootFolder(item: UploadItem, uploadedItem: UploadedItem): void { const topLevelItem = getTopLevelItem(item); - const rootFolder = this.rootFolders.get(topLevelItem) as RootFolderUpload; + const rootFolder = this.getRootFolder(topLevelItem); const isRootFolderItself = item === topLevelItem; if (isRootFolderItself) { @@ -217,8 +247,7 @@ export class Upload { } private onItemFailed(item: UploadItem, failure: UploadFailure): void { - const rootFolder = this.rootFolders.get(getTopLevelItem(item)); - if (rootFolder) rootFolder.hasFailedItems = true; + this.recordFailure(item, failure); const taskId = this.getTaskIdOf(item); if (!taskId) return; @@ -228,6 +257,8 @@ export class Upload { } private onItemCancelled(item: UploadItem): void { + this.clearFailure(item); + const taskId = this.getTaskIdOf(item); const isFileWithItsOwnTask = !!taskId && item.kind === UploadItemKind.File; if (!isFileWithItsOwnTask) return; @@ -237,9 +268,9 @@ export class Upload { } private cancelRootFolder(item: UploadFolderItem): void { - const rootFolder = this.rootFolders.get(item) as RootFolderUpload; + const rootFolder = this.getRootFolder(item); rootFolder.isCancelled = true; - this.manager.cancelItem(item); + this.cancelItem(item); if (rootFolder.createdFolder) { this.cancelledRootFolderDeletions.push(this.deleteCancelledRootFolder(item, rootFolder)); } @@ -271,10 +302,58 @@ export class Upload { merge: { status: TaskStatus.Success, itemUUID: { rootFolderUUID: createdFolder.uuid } }, }); - const hasEverythingUploaded = !rootFolder.hasFailedItems; - if (hasEverythingUploaded) { - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName: item.folder.name }); - } + const hasEverythingUploaded = this.getFailuresOf(item).size === 0; + if (hasEverythingUploaded) this.trackFolderUpload(item); + } + + private trackFolderUpload(item: UploadFolderItem): void { + referralService.trackFolderUpload(); + logNetworkInfoForUpload({ folderName: item.folder.name }); + } + + private async retry(topLevelItem: UploadItem, pickedItem?: UploadItem): Promise { + const taskId = this.getTaskIdOf(topLevelItem) as string; + const status = tasksService.findTask(taskId)?.status; + const isTaskFinished = status === TaskStatus.Success || status === TaskStatus.Error; + if (!isTaskFinished) return; + + const failures = this.getFailuresOf(topLevelItem); + const retryableFailures = [...failures].filter(([, failedItem]) => failedItem.isRetryable); + const failuresToRetry = pickedItem ? retryableFailures.filter(([item]) => item === pickedItem) : retryableFailures; + + const hasNothingToRetry = failuresToRetry.length === 0; + if (hasNothingToRetry) return; + + for (const [item, failedItem] of failuresToRetry) failures.set(item, { ...failedItem, isRetrying: true }); + this.syncFailedItemsToTask(topLevelItem); + + const rootFolder = this.rootFolders.get(topLevelItem); + const progress = rootFolder ? tasksService.findTask(taskId)?.progress : 0; + tasksService.updateTask({ taskId, merge: { status: TaskStatus.InProcess, subtitle: undefined, progress } }); + + const itemsToRetry = failuresToRetry.map(([item]) => item); + await this.uploadAndFinish(itemsToRetry, [topLevelItem]); + } + + private getFailuresOf(topLevelItem: UploadItem): Map { + return this.failuresByTopLevelItem.get(topLevelItem) as Map; + } + + private recordFailure(item: UploadItem, failure: UploadFailure): void { + const topLevelItem = getTopLevelItem(item); + const retry = () => this.retry(topLevelItem, item); + this.getFailuresOf(topLevelItem).set(item, toFailedUploadItem(item, failure, retry)); + this.syncFailedItemsToTask(topLevelItem); + } + + private clearFailure(item: UploadItem): void { + const topLevelItem = getTopLevelItem(item); + const hadFailed = this.getFailuresOf(topLevelItem).delete(item); + if (hadFailed) this.syncFailedItemsToTask(topLevelItem); + } + + private syncFailedItemsToTask(topLevelItem: UploadItem): void { + const failedItems = [...this.getFailuresOf(topLevelItem).values()]; + tasksService.updateTask({ taskId: this.getTaskIdOf(topLevelItem) as string, merge: { failedItems } }); } } diff --git a/src/app/upload/failedUploadItem.ts b/src/app/upload/failedUploadItem.ts new file mode 100644 index 000000000..3472abac0 --- /dev/null +++ b/src/app/upload/failedUploadItem.ts @@ -0,0 +1,32 @@ +import { UploadItem, UploadItemKind } from 'app/upload/types'; +import { UploadFailure } from 'app/upload/uploadFailure'; +import { FailedUploadItem } from 'app/tasks/types'; + +export const toFailedUploadItem = ( + item: UploadItem, + { isRetryable }: UploadFailure, + retry: () => Promise, +): FailedUploadItem => { + if (item.kind === UploadItemKind.Folder) { + return { + name: item.folder.name, + size: 0, + modifiedAt: Date.now(), + isFolder: true, + isRetryable, + isRetrying: false, + retry, + }; + } + + return { + name: item.file.name, + type: item.file.type, + size: item.file.size, + modifiedAt: item.file.content.lastModified, + isFolder: false, + isRetryable, + isRetrying: false, + retry, + }; +};