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:
alibildir
2026-06-25 19:10:07 +03:00
committed by GitHub
parent ce9ca1184c
commit 35d64ac7f2
4 changed files with 78 additions and 12 deletions
@@ -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(