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:
+80
-83
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user