Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ContainerBuilder } from 'diod';
import { AllParentFoldersStatusIsExists } from '../../../../context/virtual-drive/folders/application/AllParentFoldersStatusIsExists';
import { FolderCreator } from '../../../../context/virtual-drive/folders/application/create/FolderCreator';
import { FolderCreatorFromOfflineFolder } from '../../../../context/virtual-drive/folders/application/create/FolderCreatorFromOfflineFolder';
import { PendingFolderCreationTracker } from '../../../../context/virtual-drive/folders/application/create/PendingFolderCreationTracker';
import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter';
import { FolderMover } from '../../../../context/virtual-drive/folders/application/FolderMover';
import { FolderPathUpdater } from '../../../../context/virtual-drive/folders/application/FolderPathUpdater';
Expand Down Expand Up @@ -56,6 +57,8 @@ export async function registerFolderServices(builder: ContainerBuilder): Promise

builder.registerAndUse(FolderCreatorFromOfflineFolder);

builder.register(PendingFolderCreationTracker).use(PendingFolderCreationTracker).asSingleton().private();

builder.registerAndUse(FolderCreator);

builder.registerAndUse(FolderDeleter);
Expand Down
103 changes: 103 additions & 0 deletions src/apps/drive/fuse/callbacks/TrashFolderCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter';
import { SingleFolderMatchingFinder } from '../../../../context/virtual-drive/folders/application/SingleFolderMatchingFinder';
import { FolderMother } from '../../../../context/virtual-drive/folders/domain/__test-helpers__/FolderMother';
import { FolderStatuses } from '../../../../context/virtual-drive/folders/domain/FolderStatus';
import { SyncFolderMessenger } from '../../../../context/virtual-drive/folders/domain/SyncFolderMessenger';
import { ContainerMock } from '../../__mocks__/ContainerMock';
import { TrashFolderCallback } from './TrashFolderCallback';

describe('TrashFolderCallback', () => {
it('returns success even when folder deletion exceeds callback timeout', async () => {
vi.useFakeTimers();

try {
const container = new ContainerMock();
const folder = FolderMother.any();

const folderFinder = {
run: vi.fn(async () => {
return folder;
}),
} as unknown as SingleFolderMatchingFinder;

const folderDeleter = {
run: vi.fn(() => {
return new Promise<void>((resolve) => {
setTimeout(resolve, 5_000);
});
}),
} as unknown as FolderDeleter;

container.set(SingleFolderMatchingFinder, folderFinder);
container.set(FolderDeleter, folderDeleter);

const callback = new TrashFolderCallback(container as never);
const resultPromise = callback.execute('/Files/SlowFolder');

await vi.advanceTimersByTimeAsync(1_600);

const result = await resultPromise;

expect(result.isRight()).toBe(true);
expect(folderFinder.run).toHaveBeenCalledWith({
path: '/Files/SlowFolder',
status: FolderStatuses.EXISTS,
});
expect(folderDeleter.run).toHaveBeenCalledWith(folder.uuid);
} finally {
vi.useRealTimers();
}
});

it('reports issue when background deletion fails after timeout', async () => {
vi.useFakeTimers();

try {
const container = new ContainerMock();
const folder = FolderMother.any();

const folderFinder = {
run: vi.fn(async () => {
return folder;
}),
} as unknown as SingleFolderMatchingFinder;

const folderDeleter = {
run: vi.fn(() => {
return new Promise<void>((_resolve, reject) => {
setTimeout(() => {
reject(new Error('slow-delete-failed'));
}, 5_000);
});
}),
} as unknown as FolderDeleter;

const syncFolderMessenger = {
issue: vi.fn(async () => undefined),
} as unknown as SyncFolderMessenger;

container.set(SingleFolderMatchingFinder, folderFinder);
container.set(FolderDeleter, folderDeleter);
container.set(SyncFolderMessenger, syncFolderMessenger);

const callback = new TrashFolderCallback(container as never);
const resultPromise = callback.execute('/Files/SlowFolder');

await vi.advanceTimersByTimeAsync(1_600);

const result = await resultPromise;
expect(result.isRight()).toBe(true);

await vi.advanceTimersByTimeAsync(3_500);
await Promise.resolve();

expect(syncFolderMessenger.issue).toHaveBeenCalledWith({
error: 'FOLDER_TRASH_ERROR',
cause: 'UNKNOWN',
name: 'SlowFolder',
});
} finally {
vi.useRealTimers();
}
});
});
47 changes: 46 additions & 1 deletion src/apps/drive/fuse/callbacks/TrashFolderCallback.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Container } from 'diod';
import { basename } from 'path';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter';
import { SingleFolderMatchingFinder } from '../../../../context/virtual-drive/folders/application/SingleFolderMatchingFinder';
import { FolderStatuses } from '../../../../context/virtual-drive/folders/domain/FolderStatus';
import { SyncFolderMessenger } from '../../../../context/virtual-drive/folders/domain/SyncFolderMessenger';
import { NotifyFuseCallback } from './FuseCallback';

const FOLDER_TRASH_CALLBACK_TIMEOUT_MS = 1_500;

type WaitWithTimeoutPops = {
promise: Promise<void>;
timeoutMs: number;
};

async function waitWithTimeout({ promise, timeoutMs }: WaitWithTimeoutPops) {
const completion = promise.then(() => true);
const timeout = new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(false);
}, timeoutMs);
});

return Promise.race([completion, timeout]);
}

export class TrashFolderCallback extends NotifyFuseCallback {
constructor(private readonly container: Container) {
super('Trash Folder');
Expand All @@ -18,7 +37,33 @@ export class TrashFolderCallback extends NotifyFuseCallback {
status: FolderStatuses.EXISTS,
});

await this.container.get(FolderDeleter).run(folder.uuid);
const deletionPromise = this.container.get(FolderDeleter).run(folder.uuid);
const deletionCompletedInTime = await waitWithTimeout({
promise: deletionPromise,
timeoutMs: FOLDER_TRASH_CALLBACK_TIMEOUT_MS,
});

if (!deletionCompletedInTime) {
logger.warn({
msg: 'Folder deletion exceeded callback timeout. Continuing deletion in background.',
path,
timeoutMs: FOLDER_TRASH_CALLBACK_TIMEOUT_MS,
});

void deletionPromise.catch(async (error) => {
logger.error({
msg: 'Background folder deletion failed after callback timeout',
path,
error,
});

await this.container.get(SyncFolderMessenger).issue({
error: 'FOLDER_TRASH_ERROR',
cause: 'UNKNOWN',
name: basename(path),
});
});
}

return this.right();
} catch (throwed: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber<T

async on(event: TemporalFileUploadedDomainEvent): Promise<void> {
try {
this.create(event);
await this.create(event);
} catch (err) {
logger.error({
msg: '[CreateFileOnOfflineFileUploaded] Error creating file:',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FileMother } from '../../domain/__test-helpers__/FileMother';
import { FileSizeMother } from '../../domain/__test-helpers__/FileSizeMother';
import { right } from '../../../../shared/domain/Either';
import { EventBusMock } from '../../../../../context/virtual-drive/shared/__mocks__/EventBusMock';
import { PendingFolderCreationTracker } from '../../../folders/application/create/PendingFolderCreationTracker';

describe('File Creator', () => {
let remoteFileSystemMock: RemoteFileSystemMock;
Expand All @@ -25,8 +26,16 @@ describe('File Creator', () => {
const parentFolderFinder = FolderFinderFactory.existingFolder();
eventBus = new EventBusMock();
notifier = new FileSyncNotifierMock();
const pendingFolderCreationTracker = new PendingFolderCreationTracker();

SUT = new FileCreator(remoteFileSystemMock, fileRepository, parentFolderFinder, eventBus, notifier);
SUT = new FileCreator(
remoteFileSystemMock,
fileRepository,
parentFolderFinder,
eventBus,
notifier,
pendingFolderCreationTracker,
);
});

it('creates the file on the drive server', async () => {
Expand Down
59 changes: 33 additions & 26 deletions src/context/virtual-drive/files/application/create/FileCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SyncFileMessenger } from '../../domain/SyncFileMessenger';
import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem';
import { FileContentsId } from '../../domain/FileContentsId';
import { FileFolderId } from '../../domain/FileFolderId';
import { PendingFolderCreationTracker } from '../../../folders/application/create/PendingFolderCreationTracker';

@Service()
export class FileCreator {
Expand All @@ -21,41 +22,47 @@ export class FileCreator {
private readonly parentFolderFinder: ParentFolderFinder,
private readonly eventBus: EventBus,
private readonly notifier: SyncFileMessenger,
private readonly pendingFolderCreationTracker: PendingFolderCreationTracker,
) {}

async run(path: string, contentsId: string, size: number): Promise<File> {
try {
const fileSize = new FileSize(size);
const fileContentsId = new FileContentsId(contentsId);
const filePath = new FilePath(path);
const file = await this.pendingFolderCreationTracker.runAfterParentCreations({
path,
action: async () => {
const fileSize = new FileSize(size);
const fileContentsId = new FileContentsId(contentsId);
const filePath = new FilePath(path);

const folder = await this.parentFolderFinder.run(filePath);
const fileFolderId = new FileFolderId(folder.id);
const folder = await this.parentFolderFinder.run(filePath);
const fileFolderId = new FileFolderId(folder.id);

const either = await this.remote.persist({
contentsId: fileContentsId,
path: filePath,
size: fileSize,
folderId: fileFolderId,
folderUuid: folder.uuid,
});
const either = await this.remote.persist({
contentsId: fileContentsId,
path: filePath,
size: fileSize,
folderId: fileFolderId,
folderUuid: folder.uuid,
});

if (either.isLeft()) {
throw either.getLeft();
}
if (either.isLeft()) {
throw either.getLeft();
}

const { modificationTime, id, uuid, createdAt } = either.getRight();
const { modificationTime, id, uuid, createdAt } = either.getRight();

const file = File.create({
id,
uuid,
contentsId: fileContentsId.value,
folderId: fileFolderId.value,
createdAt,
modificationTime,
path: filePath.value,
size: fileSize.value,
updatedAt: modificationTime,
return File.create({
id,
uuid,
contentsId: fileContentsId.value,
folderId: fileFolderId.value,
createdAt,
modificationTime,
path: filePath.value,
size: fileSize.value,
updatedAt: modificationTime,
});
},
});

await this.repository.upsert(file);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Either, right } from '../../../shared/domain/Either';
import { Either, left, right } from '../../../shared/domain/Either';
import { Folder } from '../domain/Folder';
import { FolderId } from '../domain/FolderId';
import { FolderPath } from '../domain/FolderPath';
Expand Down Expand Up @@ -39,6 +39,15 @@ export class FolderRemoteFileSystemMock implements RemoteFileSystem {
);
}

shouldFailPersistWith(plainName: string, parentFolderUuid: string, error: RemoteFileSystemErrors) {
this.persistMock(plainName, parentFolderUuid);
this.persistMock.mockResolvedValueOnce(left(error));
}

shouldFindFolder(folder?: Folder) {
this.searchWithMock.mockResolvedValueOnce(folder);
}

shouldTrash(folder: Folder, error?: Error) {
this.trashMock(folder.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FolderRemoteFileSystemMock } from '../../__mocks__/FolderRemoteFileSyst
import { FolderRepositoryMock } from '../../__mocks__/FolderRepositoryMock';
import { FolderPathMother } from '../../domain/__test-helpers__/FolderPathMother';
import { FolderMother } from '../../domain/__test-helpers__/FolderMother';
import { PendingFolderCreationTracker } from './PendingFolderCreationTracker';

describe('Folder Creator', () => {
let repository: FolderRepositoryMock;
Expand All @@ -21,10 +22,11 @@ describe('Folder Creator', () => {
repository = new FolderRepositoryMock();
remote = new FolderRemoteFileSystemMock();
eventBus = new EventBusMock();
const pendingFolderCreationTracker = new PendingFolderCreationTracker();

const parentFolderFinder = new ParentFolderFinder(repository);

SUT = new FolderCreator(repository, parentFolderFinder, remote, eventBus);
SUT = new FolderCreator(repository, parentFolderFinder, remote, eventBus, pendingFolderCreationTracker);
});

it('throws an InvalidArgument error if the path is not a valid posix path', async () => {
Expand Down Expand Up @@ -106,4 +108,33 @@ describe('Folder Creator', () => {
expect.arrayContaining([expect.objectContaining({ aggregateId: createdFolder.uuid })]),
);
});

it('throws when remote folder creation fails with non-recoverable error', async () => {
const path = FolderPathMother.any();
const parent = FolderMother.fromPartial({ path: path.dirname() });

remote.shouldFailPersistWith(path.name(), parent.uuid, 'UNHANDLED');
repository.matchingPartialMock.mockReturnValueOnce([]).mockReturnValueOnce([parent]).mockReturnValueOnce([parent]);

await expect(SUT.run(path.value)).rejects.toThrow(`Could not create folder ${path.value}: UNHANDLED`);
});

it('recovers from ALREADY_EXISTS by finding the folder remotely', async () => {
const path = FolderPathMother.any();
const parent = FolderMother.fromPartial({ path: path.dirname() });
const existingFolder = FolderMother.fromPartial({
path: path.value,
parentId: parent.id,
});

remote.shouldFailPersistWith(path.name(), parent.uuid, 'ALREADY_EXISTS');
remote.shouldFindFolder(existingFolder);

repository.matchingPartialMock.mockReturnValueOnce([]).mockReturnValueOnce([parent]).mockReturnValueOnce([parent]);

await SUT.run(path.value);

expect(repository.addMock).toBeCalledWith(expect.objectContaining({ uuid: existingFolder.uuid }));
expect(eventBus.publishMock).not.toBeCalled();
});
});
Loading
Loading