From d583984bf0b766f47d8c845d80867417b0c70bc6 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Tue, 14 Apr 2026 12:43:31 +0400 Subject: [PATCH] fix(api-key): batch role resolution with DataLoader to fix N+1 (#19590) ## Summary The `role` @ResolveField on `ApiKeyResolver` calls `getRolesByApiKeys` with a single-element array per API key. When a query returns N API keys, this produces N separate DB queries to resolve their roles. This adds an `apiKeyRoleLoader` to the existing DataLoader infrastructure. All API key IDs in a single GraphQL request are collected and resolved in one batched query. - Before: N queries (one per API key) - After: 1 query (batched via DataLoader) ## Changes - `dataloader.service.ts` - new `createApiKeyRoleLoader` method, delegates to `ApiKeyRoleService.getRolesByApiKeys` - `dataloader.interface.ts` - `apiKeyRoleLoader` added to `IDataloaders` - `dataloader.module.ts` - import `ApiKeyModule` so `ApiKeyRoleService` is available - `api-key.resolver.ts` - `role()` now uses `context.loaders.apiKeyRoleLoader.load()` instead of calling the service directly ## Test plan - [ ] Verify `apiKeys { id role { label } }` query returns the same results as before - [ ] Confirm only 1 role_target query fires regardless of how many API keys are returned --------- Co-authored-by: Charles Bochet --- .../core-modules/api-key/api-key.resolver.ts | 17 ++------ .../api-key/services/api-key-role.service.ts | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts index 7723e96015..e1a084d4a3 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts @@ -5,6 +5,7 @@ import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialE import { PermissionFlagType } from 'twenty-shared/constants'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input'; import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.input'; @@ -17,7 +18,6 @@ import { import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @@ -126,20 +126,9 @@ export class ApiKeyResolver { @Parent() apiKey: ApiKeyEntity, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const rolesMap = await this.apiKeyRoleService.getRolesByApiKeys({ - apiKeyIds: [apiKey.id], + return this.apiKeyRoleService.getRoleDtoByApiKeyId({ + apiKeyId: apiKey.id, workspaceId: workspace.id, }); - - const role = rolesMap.get(apiKey.id); - - if (!role) { - throw new ApiKeyException( - `API key ${apiKey.id} has no role assigned`, - ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED, - ); - } - - return role; } } diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts index 5d9a9a8d82..9eafcef398 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts @@ -9,10 +9,12 @@ import { ApiKeyException, ApiKeyExceptionCode, } from 'src/engine/core-modules/api-key/exceptions/api-key.exception'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util'; import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -80,6 +82,43 @@ export class ApiKeyRoleService { return roleId; } + async getRoleDtoByApiKeyId({ + apiKeyId, + workspaceId, + }: { + apiKeyId: string; + workspaceId: string; + }): Promise { + const { apiKeyRoleMap, flatRoleMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'apiKeyRoleMap', + 'flatRoleMaps', + ]); + + const roleId = apiKeyRoleMap[apiKeyId]; + + if (!isDefined(roleId)) { + throw new ApiKeyException( + `API key ${apiKeyId} has no role assigned`, + ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED, + ); + } + + const flatRole = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: roleId, + flatEntityMaps: flatRoleMaps, + }); + + if (!isDefined(flatRole)) { + throw new ApiKeyException( + `Role ${roleId} not found for API key ${apiKeyId}`, + ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED, + ); + } + + return fromFlatRoleToRoleDto(flatRole); + } + private async validateAssignRoleInput({ apiKeyId, workspaceId,