fix(server): wrap file storage upsert and read in transaction (#21924)
## Summary This PR fixes a race condition occurring during file and image uploads in environments that use Pgpool-II or similar connection poolers with read-replica scaling. Currently, in `FileStorageService.writeFile`, `fileRepository.upsert` writes the file record to the primary database node. However, the immediate subsequent call to `fileRepository.findOneOrFail` is executed outside of a transaction. Consequently, connection poolers like Pgpool can route this `SELECT` query to a read-replica. Due to replication lag, the replica may not yet reflect the newly inserted record, throwing an `EntityNotFoundError` and failing the upload process (even though the file is successfully saved in S3 and the database). This PR wraps both operations in a TypeORM database transaction when `queryRunner` is not provided. This ensures that the `SELECT` query correctly targets the primary node, guaranteeing immediate read-after-write consistency. ## Affected version - Twenty Self-hosted (e.g. `v2.14.x`) configured with Pgpool/read-replicas. ## Changes Made - **`file-storage.service.ts`**: Wrapped `transactionalFileRepo.upsert` and `transactionalFileRepo.findOneOrFail` within `this.applicationRepository.manager.transaction` to ensure read-after-write consistency. ## How to Test 1. Set up Twenty in a self-hosted environment using Pgpool configured with load balancing / read-replicas. 2. Attempt to upload a file to a `Files` custom field or upload an image as an organization logo. 3. Observe that the upload completes successfully without throwing an `EntityNotFoundError` in the server logs. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21924?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Ali Bildir <[alibildir@gmail.com]> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+8
-4
@@ -23,7 +23,7 @@ describe('FileStorageService', () => {
|
||||
|
||||
const mockFileRepository = {
|
||||
save: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
upsertAndReturnOne: jest.fn(),
|
||||
findOneOrFail: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
@@ -179,7 +179,11 @@ describe('FileStorageService', () => {
|
||||
id: 'app-id',
|
||||
universalIdentifier: 'app-uid',
|
||||
});
|
||||
mockFileRepository.upsert.mockResolvedValue(undefined);
|
||||
mockFileRepository.upsertAndReturnOne.mockResolvedValue({
|
||||
id: 'file-id',
|
||||
path: 'BuiltFrontComponent/file.mjs',
|
||||
mimeType: 'application/javascript',
|
||||
});
|
||||
mockFileRepository.findOneOrFail.mockResolvedValue({
|
||||
id: 'file-id',
|
||||
path: 'BuiltFrontComponent/file.mjs',
|
||||
@@ -442,10 +446,10 @@ describe('FileStorageService', () => {
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
);
|
||||
expect(mockFileRepository.upsert).toHaveBeenCalledWith(
|
||||
expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
expect.anything(),
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export class FileStorageService {
|
||||
sourceFile: persistedSourceFile,
|
||||
});
|
||||
|
||||
await fileRepository.upsert(
|
||||
return fileRepository.upsertAndReturnOne(
|
||||
workspaceId,
|
||||
{
|
||||
path: filePath,
|
||||
@@ -185,13 +185,6 @@ export class FileStorageService {
|
||||
},
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
|
||||
return fileRepository.findOneOrFail(workspaceId, {
|
||||
where: {
|
||||
path: filePath,
|
||||
applicationId: application.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPresignedUrl(
|
||||
|
||||
Reference in New Issue
Block a user