diff --git a/src/backend/features/virtual-drive/services/operations/release.service.test.ts b/src/backend/features/virtual-drive/services/operations/release.service.test.ts index 1d024bf2b..5baabfb41 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.test.ts @@ -7,6 +7,7 @@ import { TemporalFileDeleter } from '../../../../../context/storage/TemporalFile import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/application/search/FirstsFileSearcher'; import { File, FileAttributes } from '../../../../../context/virtual-drive/files/domain/File'; +import { ContentsId } from '../../../../../apps/main/database/entities/DriveFile'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; import { call, calls } from '../../../../../../tests/vitest/utils.helper'; @@ -78,7 +79,7 @@ describe('release', () => { const temporalFile = createTemporalFile('/Documents/report.pdf'); finder.run.mockResolvedValue(temporalFile); fileSearcher.run.mockResolvedValue(undefined); - uploader.run.mockResolvedValue('contents-id-123'); + uploader.run.mockResolvedValue('contents-id-123' as ContentsId); const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); @@ -92,7 +93,7 @@ describe('release', () => { const existingFile = File.from(fileAttrs); finder.run.mockResolvedValue(temporalFile); fileSearcher.run.mockResolvedValue(existingFile); - uploader.run.mockResolvedValue('new-contents-id'); + uploader.run.mockResolvedValue('new-contents-id' as ContentsId); const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); @@ -114,6 +115,37 @@ describe('release', () => { expect(error?.code).toBe(FuseCodes.EIO); call(deleter.run).toStrictEqual('/Documents/report.pdf'); }); + + it('should skip the second release when an upload for the same path is already in progress', async () => { + const temporalFile = createTemporalFile('/Documents/report.pdf'); + finder.run.mockResolvedValue(temporalFile); + fileSearcher.run.mockResolvedValue(undefined); + + // First release never resolves during the test — simulates a long in-progress upload + let resolveFirstUpload!: () => void; + uploader.run.mockReturnValue( + new Promise((resolve) => { + resolveFirstUpload = () => resolve('contents-id-123' as ContentsId); + }), + ); + + const first = release({ path: '/Documents/report.pdf', processName: 'proc-a', container }); + + await Promise.resolve(); + + const { data: data2, error: error2 } = await release({ + path: '/Documents/report.pdf', + processName: 'proc-b', + container, + }); + + expect(error2).toBeUndefined(); + expect(data2).toBeUndefined(); + calls(uploader.run).toHaveLength(1); + + resolveFirstUpload(); + await first; + }); }); describe('when finder throws an unexpected error', () => { diff --git a/src/backend/features/virtual-drive/services/operations/release.service.ts b/src/backend/features/virtual-drive/services/operations/release.service.ts index 017863a7e..f207f0f78 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.ts @@ -7,11 +7,22 @@ import { TemporalFileUploader } from '../../../../../context/storage/TemporalFil import { TemporalFileDeleter } from '../../../../../context/storage/TemporalFiles/application/deletion/TemporalFileDeleter'; import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/application/search/FirstsFileSearcher'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; + type Props = { path: string; processName: string; container: Container; }; + +// v.2.6.0 +// Esteban Galvis Triana +// For files with unusual extensions or when the system has to figure +// out which app to use to open them—two file descriptors end up being created: +// one for metadata and one for the actual content. +// The issue is that when each descriptor closes, it triggers a release, +// resulting in a duplicate request to create the file remotely. +const uploadsInProgress = new Set(); + export async function release({ path, processName, container }: Props): Promise> { try { const temporalFile = await container.get(TemporalFileByPathFinder).run(path); @@ -27,12 +38,18 @@ export async function release({ path, processName, container }: Props): Promise< return { data: undefined }; } - const existingFile = await container.get(FirstsFileSearcher).run({ path, status: FileStatuses.EXISTS }); - const replaces = existingFile - ? { contentsId: existingFile.contentsId, name: existingFile.name, extension: existingFile.type } - : undefined; + if (uploadsInProgress.has(path)) { + logger.debug({ msg: '[Release] Upload already in progress, skipping duplicate release', path, processName }); + return { data: undefined }; + } + uploadsInProgress.add(path); try { + const existingFile = await container.get(FirstsFileSearcher).run({ path, status: FileStatuses.EXISTS }); + const replaces = existingFile + ? { contentsId: existingFile.contentsId, name: existingFile.name, extension: existingFile.type } + : undefined; + await container.get(TemporalFileUploader).run(temporalFile, replaces); logger.debug({ msg: '[Release] Temporal file uploaded', path, processName }); return { data: undefined }; @@ -40,6 +57,8 @@ export async function release({ path, processName, container }: Props): Promise< logger.error({ msg: '[Release] Upload failed, deleting temporal file', error: uploadError, path, processName }); await container.get(TemporalFileDeleter).run(path); return { error: new FuseIOError('Upload failed due to insufficient storage or network issues.') }; + } finally { + uploadsInProgress.delete(path); } } catch (err: unknown) { logger.error({ msg: '[Release] Unexpected error', error: err, path, processName });