Fix rolesPermissions cache query cartesian product (62k → 162 rows) (#19511)

## Summary
- **Split the `rolesPermissions` cache query into parallel queries** to
eliminate a 5-way LEFT JOIN cartesian product. A workspace with a role
having 5 objectPermissions × 4 permissionFlags × 124 fieldPermissions ×
5 RLP predicates × 5 RLP groups was returning 62,000 rows from just ~162
distinct records, causing 10s+ query timeouts on view updates.
- **Added missing `roleId` index on `permissionFlag` table** — the only
permission table without one, which was causing sequential scans on the
JOIN.
This commit is contained in:
Charles Bochet
2026-04-09 18:13:33 +02:00
committed by GitHub
parent 082822b790
commit 07745947ee
5 changed files with 121 additions and 34 deletions
@@ -0,0 +1,17 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.22.0', 1775749486425)
export class AutoGeneratedFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE INDEX "IDX_PERMISSION_FLAG_ROLE_ID" ON "core"."permissionFlag" ("roleId") ',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "core"."IDX_PERMISSION_FLAG_ROLE_ID"');
}
}
@@ -3,9 +3,11 @@
import { AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775129420309-add-view-field-group-id-index-on-view-field';
import { MigrateMessagingCalendarToCoreFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775165049548-migrate-messaging-calendar-to-core';
import { AddEmailThreadWidgetTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-instance-command-fast-1775200000000-add-email-thread-widget-type';
import { AutoGeneratedFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775749486425-auto-generated';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
MigrateMessagingCalendarToCoreFastInstanceCommand,
AddEmailThreadWidgetTypeFastInstanceCommand,
AutoGeneratedFastInstanceCommand,
];
@@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -16,6 +17,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Entity('permissionFlag')
@Unique('IDX_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE', ['flag', 'roleId'])
@Index('IDX_PERMISSION_FLAG_ROLE_ID', ['roleId'])
export class PermissionFlagEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -2,20 +2,26 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { IsNull, Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { regroupEntitiesByRelatedEntityId } from 'src/engine/workspace-cache/utils/regroup-entities-by-related-entity-id';
const WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.workflow.universalIdentifier,
@@ -31,6 +37,16 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(ObjectPermissionEntity)
private readonly objectPermissionRepository: Repository<ObjectPermissionEntity>,
@InjectRepository(PermissionFlagEntity)
private readonly permissionFlagRepository: Repository<PermissionFlagEntity>,
@InjectRepository(FieldPermissionEntity)
private readonly fieldPermissionRepository: Repository<FieldPermissionEntity>,
@InjectRepository(RowLevelPermissionPredicateEntity)
private readonly rowLevelPermissionPredicateRepository: Repository<RowLevelPermissionPredicateEntity>,
@InjectRepository(RowLevelPermissionPredicateGroupEntity)
private readonly rowLevelPermissionPredicateGroupRepository: Repository<RowLevelPermissionPredicateGroupEntity>,
) {
super();
}
@@ -38,25 +54,74 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
async computeForCache(
workspaceId: string,
): Promise<ObjectsPermissionsByRoleId> {
const roles = await this.roleRepository.find({
where: {
workspaceId,
},
relations: [
'objectPermissions',
'permissionFlags',
'fieldPermissions',
'rowLevelPermissionPredicates',
'rowLevelPermissionPredicateGroups',
],
});
const [
roles,
objectPermissions,
permissionFlags,
fieldPermissions,
rowLevelPermissionPredicates,
rowLevelPermissionPredicateGroups,
workspaceObjectMetadataCollection,
] = await Promise.all([
this.roleRepository.find({
where: { workspaceId },
}),
this.objectPermissionRepository.find({
where: { workspaceId },
}),
this.permissionFlagRepository.find({
where: { workspaceId },
}),
this.fieldPermissionRepository.find({
where: { workspaceId },
}),
this.rowLevelPermissionPredicateRepository.find({
where: { workspaceId, deletedAt: IsNull() },
}),
this.rowLevelPermissionPredicateGroupRepository.find({
where: { workspaceId, deletedAt: IsNull() },
}),
this.getWorkspaceObjectMetadataCollection(workspaceId),
]);
const workspaceObjectMetadataCollection =
await this.getWorkspaceObjectMetadataCollection(workspaceId);
const objectPermissionsByRoleId =
regroupEntitiesByRelatedEntityId<'objectPermission'>({
entities: objectPermissions,
foreignKey: 'roleId',
});
const permissionFlagsByRoleId =
regroupEntitiesByRelatedEntityId<'permissionFlag'>({
entities: permissionFlags,
foreignKey: 'roleId',
});
const fieldPermissionsByRoleId =
regroupEntitiesByRelatedEntityId<'fieldPermission'>({
entities: fieldPermissions,
foreignKey: 'roleId',
});
const rowLevelPermissionPredicatesByRoleId =
regroupEntitiesByRelatedEntityId<'rowLevelPermissionPredicate'>({
entities: rowLevelPermissionPredicates,
foreignKey: 'roleId',
});
const rowLevelPermissionPredicateGroupsByRoleId =
regroupEntitiesByRelatedEntityId<'rowLevelPermissionPredicateGroup'>({
entities: rowLevelPermissionPredicateGroups,
foreignKey: 'roleId',
});
const permissionsByRoleId: ObjectsPermissionsByRoleId = {};
for (const role of roles) {
const roleObjectPermissions =
objectPermissionsByRoleId.get(role.id) ?? [];
const rolePermissionFlags = permissionFlagsByRoleId.get(role.id) ?? [];
const roleFieldPermissions = fieldPermissionsByRoleId.get(role.id) ?? [];
const roleRowLevelPermissionPredicates =
rowLevelPermissionPredicatesByRoleId.get(role.id) ?? [];
const roleRowLevelPermissionPredicateGroups =
rowLevelPermissionPredicateGroupsByRoleId.get(role.id) ?? [];
const objectRecordsPermissions: ObjectsPermissions = {};
for (const objectMetadata of workspaceObjectMetadataCollection) {
@@ -77,14 +142,17 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
universalIdentifier as (typeof WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS)[number],
)
) {
const hasWorkflowsPermissions = this.hasWorkflowsPermissions(role);
const hasWorkflowsPermissions = this.hasWorkflowsPermissions(
role,
rolePermissionFlags,
);
canRead = hasWorkflowsPermissions;
canUpdate = hasWorkflowsPermissions;
canSoftDelete = hasWorkflowsPermissions;
canDestroy = hasWorkflowsPermissions;
} else {
const objectRecordPermissionsOverride = role.objectPermissions.find(
const objectRecordPermissionsOverride = roleObjectPermissions.find(
(objectPermission) =>
objectPermission.objectMetadataId === objectMetadataId,
);
@@ -111,12 +179,12 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
canDestroy,
);
const fieldPermissions = role.fieldPermissions.filter(
const fieldPermissionsForObject = roleFieldPermissions.filter(
(fieldPermission) =>
fieldPermission.objectMetadataId === objectMetadataId,
);
for (const fieldPermission of fieldPermissions) {
for (const fieldPermission of fieldPermissionsForObject) {
const isFieldLabelIdentifier =
fieldPermission.fieldMetadataId ===
objectMetadata.labelIdentifierFieldMetadataId;
@@ -141,14 +209,12 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
canSoftDeleteObjectRecords: canSoftDelete,
canDestroyObjectRecords: canDestroy,
restrictedFields,
rowLevelPermissionPredicates:
role.rowLevelPermissionPredicates.filter(
(rowLevelPermissionPredicate) =>
rowLevelPermissionPredicate.objectMetadataId ===
objectMetadataId,
),
rowLevelPermissionPredicates: roleRowLevelPermissionPredicates.filter(
(rowLevelPermissionPredicate) =>
rowLevelPermissionPredicate.objectMetadataId === objectMetadataId,
),
rowLevelPermissionPredicateGroups:
role.rowLevelPermissionPredicateGroups.filter(
roleRowLevelPermissionPredicateGroups.filter(
(rowLevelPermissionPredicateGroup) =>
rowLevelPermissionPredicateGroup.objectMetadataId ===
objectMetadataId,
@@ -180,10 +246,13 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
return workspaceObjectMetadata;
}
private hasWorkflowsPermissions(role: RoleEntity): boolean {
private hasWorkflowsPermissions(
role: RoleEntity,
permissionFlags: PermissionFlagEntity[],
): boolean {
const hasWorkflowsPermissionFromRole = role.canUpdateAllSettings;
const hasWorkflowsPermissionsFromSettingPermissions = isDefined(
role.permissionFlags.find(
permissionFlags.find(
(permissionFlag) =>
permissionFlag.flag === PermissionFlagType.WORKFLOWS,
),
@@ -17,7 +17,7 @@ export const regroupEntitiesByRelatedEntityId = <T extends AllMetadataName>({
entities,
foreignKey,
}: RegroupEntitiesByRelatedEntityIdArgs<T>) => {
const entitiesByRelatedEntityId = new Map<string, RegroupedEntity[]>();
const entitiesByRelatedEntityId = new Map<string, MetadataEntity<T>[]>();
for (const entity of entities) {
const currentRelatedEntityId = entity[
@@ -32,10 +32,7 @@ export const regroupEntitiesByRelatedEntityId = <T extends AllMetadataName>({
entitiesByRelatedEntityId.set(currentRelatedEntityId, []);
}
entitiesByRelatedEntityId.get(currentRelatedEntityId)!.push({
id: entity.id,
universalIdentifier: entity.universalIdentifier,
});
entitiesByRelatedEntityId.get(currentRelatedEntityId)!.push(entity);
}
return entitiesByRelatedEntityId;