Fix QueryRunnerAlreadyReleasedError in sign-in-up service (#20734)

## Context

When signing up on a new workspace,
`SignInUpService.signUpOnNewWorkspace`
manually drove a transaction with `createQueryRunner` /
`startTransaction` /
`commitTransaction` / `rollbackTransaction` / `release`.

If the underlying Postgres connection dropped mid-transaction
(`idle_in_transaction_session_timeout`, server-side termination), the
`pg`
client's `'error'` event fires. TypeORM's connect-time listener responds
by
calling `release()` on the `QueryRunner`, which sets `isReleased = true`
but
deliberately does **not** touch `isTransactionActive`.

The `catch` branch then hit:

```ts
if (queryRunner.isTransactionActive) {
  await queryRunner.rollbackTransaction(); // throws QueryRunnerAlreadyReleasedError
}
throw error;
```
Error from Sentry
```typescript
QueryRunnerAlreadyReleasedError: Query runner already released. Cannot run queries anymore.
    at PostgresQueryRunner.query (.../PostgresQueryRunner.js:177)
    at PostgresQueryRunner.rollbackTransaction (.../PostgresQueryRunner.js:167)
    at SignInUpService.signUpOnNewWorkspace (.../sign-in-up.service.js:370)
```

## Changes

Replaced the hand-rolled transaction with
this.dataSource.transaction(...).
TypeORM's built-in wrapper already does what we need:

- starts/commits/rolls back the transaction
- wraps rollback in try { ... } catch { /* ignore */ }, so a connection
drop no longer masks the real error
- releases the QueryRunner unconditionally

## Note
Other fix would have been to do this
```typescript
  if (queryRunner.isTransactionActive && **!queryRunner.isReleased**) {
     try {
       await queryRunner.rollbackTransaction();
     } catch {
```
This commit is contained in:
Weiko
2026-05-19 17:32:36 +02:00
committed by GitHub
parent d345a6b2d6
commit 265d2edc83
@@ -502,113 +502,110 @@ export class SignInUpService {
const workspaceId = v4();
const workspaceCustomApplicationId = v4();
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const workspaceToCreate = this.workspaceRepository.create({
id: workspaceId,
subdomain: await this.subdomainManagerService.generateSubdomain(
isWorkEmailFound ? { userEmail: email } : {},
),
workspaceCustomApplicationId,
displayName: '',
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});
const { user, workspace } = await this.dataSource.transaction(
async (entityManager) => {
const queryRunner = entityManager.queryRunner as QueryRunner;
const workspace = await queryRunner.manager.save(
WorkspaceEntity,
workspaceToCreate,
);
const customApplication =
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
workspaceId,
applicationUniversalIdentifier:
customApplication.universalIdentifier,
queryRunner,
const workspaceToCreate = this.workspaceRepository.create({
id: workspaceId,
subdomain: await this.subdomainManagerService.generateSubdomain(
isWorkEmailFound ? { userEmail: email } : {},
),
workspaceCustomApplicationId,
displayName: '',
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});
if (isDefined(logoFile)) {
await queryRunner.manager.update(
const workspace = await queryRunner.manager.save(
WorkspaceEntity,
{ id: workspaceId },
{ logoFileId: logoFile.id },
workspaceToCreate,
);
}
}
const isExistingUser = userData.type === 'existingUser';
const user = isExistingUser
? userData.existingUser
: await this.saveNewUser(
userData.newUserWithPicture,
const customApplication =
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
workspaceId,
applicationUniversalIdentifier:
customApplication.universalIdentifier,
queryRunner,
});
if (isDefined(logoFile)) {
await queryRunner.manager.update(
WorkspaceEntity,
{ id: workspaceId },
{ logoFileId: logoFile.id },
);
}
}
const isExistingUser = userData.type === 'existingUser';
const user = isExistingUser
? userData.existingUser
: await this.saveNewUser(
userData.newUserWithPicture,
{
canImpersonate: shouldGrantServerAdmin,
canAccessFullAdminPanel: shouldGrantServerAdmin,
},
queryRunner,
);
await this.userWorkspaceService.create(
{
canImpersonate: shouldGrantServerAdmin,
canAccessFullAdminPanel: shouldGrantServerAdmin,
userId: user.id,
workspaceId: workspace.id,
isExistingUser,
pictureUrl: isExistingUser
? undefined
: userData.newUserWithPicture.picture,
applicationUniversalIdentifier:
customApplication.universalIdentifier,
},
queryRunner,
);
await this.userWorkspaceService.create(
{
userId: user.id,
workspaceId: workspace.id,
isExistingUser,
pictureUrl: isExistingUser
? undefined
: userData.newUserWithPicture.picture,
applicationUniversalIdentifier: customApplication.universalIdentifier,
},
queryRunner,
);
await this.activateOnboardingForUser(
{
user,
workspace,
shouldShowConnectAccountStep: true,
},
queryRunner,
);
await this.activateOnboardingForUser(
{
user,
workspace,
shouldShowConnectAccountStep: true,
},
queryRunner,
);
await this.onboardingService.setOnboardingInviteTeamPending(
{
workspaceId: workspace.id,
value: true,
},
queryRunner,
);
await this.onboardingService.setOnboardingInviteTeamPending(
{
workspaceId: workspace.id,
value: true,
return { user, workspace };
},
queryRunner,
);
await queryRunner.commitTransaction();
void this.auditService
.createContext({ workspaceId })
.insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {});
return { user, workspace };
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);