Improve performances (#14869)

## Improvements

- Add logs to all gql operations and rest calls to help debug CPU issues
on the backend. These are temporary and should be removed
- Remove nested relations from workflowVersions load (used to add manual
triggers in the side bar). On some workspaces this call result in a
response of 4MB which is heavy on CPU
- Investigated Redis Usage ==> made a few improvements, we are should
still migrate to the new cache service once available
- investigated db calls in messaging / calendar fetch list + workflow
enqueue run cron jobs. Everything seems to be properly batched
This commit is contained in:
Charles Bochet
2025-10-03 17:22:03 +02:00
committed by GitHub
parent f973a1bcdb
commit c5cdf45f85
19 changed files with 308 additions and 199 deletions
@@ -43,14 +43,20 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
return {
onRequest: async ({ endResponse, serverContext }) => {
// TODO: we should probably override the graphql-yoga request type to include the workspace and locale
const request = (serverContext as unknown as { req: Request }).req;
if (!request.workspace?.id) {
return;
}
if (!config.operationsToCache.includes(getOperationName(serverContext))) {
return;
}
const cacheKey = computeCacheKey({
operationName: getOperationName(serverContext),
// TODO: we should probably override the graphql-yoga request type to include the workspace and locale
request: (serverContext as unknown as { req: Request }).req,
request,
});
const cachedResponse = await config.cacheGetter(cacheKey);
@@ -61,13 +67,19 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
}
},
onResponse: async ({ response, serverContext }) => {
const request = (serverContext as unknown as { req: Request }).req;
if (!request.workspace?.id) {
return;
}
if (!config.operationsToCache.includes(getOperationName(serverContext))) {
return;
}
const cacheKey = computeCacheKey({
operationName: getOperationName(serverContext),
request: (serverContext as unknown as { req: Request }).req,
request,
});
const cachedResponse = await config.cacheGetter(cacheKey);
@@ -2,6 +2,7 @@ import {
Controller,
Delete,
Get,
Logger,
Patch,
Post,
Put,
@@ -22,10 +23,14 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(RestApiExceptionFilter)
export class RestApiCoreController {
private readonly logger = new Logger(RestApiCoreController.name);
constructor(private readonly restApiCoreService: RestApiCoreService) {}
@Post('batch/*')
async handleApiPostBatch(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing BATCH request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.createMany(request);
res.status(201).send(result);
@@ -33,6 +38,9 @@ export class RestApiCoreController {
@Post('*/duplicates')
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing DUPLICATES request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.findDuplicates(request);
res.status(200).send(result);
@@ -40,6 +48,9 @@ export class RestApiCoreController {
@Post('*')
async handleApiPost(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing POST request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.createOne(request);
res.status(201).send(result);
@@ -47,6 +58,9 @@ export class RestApiCoreController {
@Get('*')
async handleApiGet(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing GET request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.get(request);
res.status(200).send(result);
@@ -54,6 +68,9 @@ export class RestApiCoreController {
@Delete('*')
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing DELETE request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.delete(request);
res.status(200).send(result);
@@ -61,6 +78,9 @@ export class RestApiCoreController {
@Patch('*')
async handleApiPatch(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing PATCH request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.update(request);
res.status(200).send(result);
@@ -71,6 +91,9 @@ export class RestApiCoreController {
// of PATCH, and because the PUT verb is often used as a PATCH.
@Put('*')
async handleApiPut(@Req() request: Request, @Res() res: Response) {
this.logger.log(
`[REST API] Processing PUT request to ${request.path} on workspace ${request.workspaceId}`,
);
const result = await this.restApiCoreService.update(request);
res.status(200).send(result);
@@ -15,7 +15,9 @@ export class CacheStorageService {
) {}
async get<T>(key: string): Promise<T | undefined> {
return this.cache.get(this.getKey(key));
const value = await this.cache.get<T>(this.getKey(key));
return value;
}
async set<T>(key: string, value: T, ttl?: Milliseconds) {
@@ -95,6 +95,11 @@ export const useGraphQLErrorHandlerHook = <
'Anonymous Operation';
const workspaceInfo = extractWorkspaceInfo(args.contextValue.req);
// eslint-disable-next-line no-console
console.log(
`[GQL Execute] Processing GQL query ${opName} on workspace ${workspaceInfo?.id}`,
);
return {
onExecuteDone(payload) {
const handleResult: OnExecuteDoneHookResultOnNextHook<object> = ({
@@ -10,6 +10,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
imports: [
TypeOrmModule.forFeature([Workspace, FeatureFlag]),
WorkspaceCacheStorageModule,
WorkspaceCacheStorageModule,
],
providers: [WorkspaceFeatureFlagsMapCacheService],
exports: [WorkspaceFeatureFlagsMapCacheService],
@@ -7,7 +7,7 @@ import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interf
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
const FEATURE_FLAG_MAP = 'Feature flag map';
@@ -20,6 +20,10 @@ export class WorkspaceFeatureFlagsMapCacheService {
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectRepository(FeatureFlag)
private readonly featureFlagRepository: Repository<FeatureFlag>,
private readonly getFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
FeatureFlagMap
>,
) {}
async getWorkspaceFeatureFlagsMap({
@@ -38,7 +42,7 @@ export class WorkspaceFeatureFlagsMapCacheService {
}: {
workspaceId: string;
}) {
return getFromCacheWithRecompute<string, FeatureFlagMap>({
return this.getFromCacheWithRecomputeService.getFromCacheWithRecompute({
workspaceId,
getCacheData: () =>
this.workspaceCacheStorageService.getFeatureFlagsMap(workspaceId),
@@ -49,7 +53,6 @@ export class WorkspaceFeatureFlagsMapCacheService {
recomputeCache: (params) => this.recomputeFeatureFlagsMapCache(params),
cachedEntityName: FEATURE_FLAG_MAP,
exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND,
logger: this.logger,
});
}
@@ -98,6 +98,14 @@ export class WorkspacePermissionsCacheStorageService {
);
}
getUserWorkspaceRoleMapVersion(
workspaceId: string,
): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapVersion}:${workspaceId}`,
);
}
removeUserWorkspaceRoleMap(workspaceId: string) {
return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`,
@@ -126,6 +134,14 @@ export class WorkspacePermissionsCacheStorageService {
);
}
async getApiKeyRoleMapVersion(
workspaceId: string,
): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataPermissionsApiKeyRoleMapVersion}:${workspaceId}`,
);
}
async removeApiKeyRoleMap(workspaceId: string): Promise<void> {
await Promise.all([
this.cacheStorageService.del(
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Record } from 'cloudflare/core';
import {
ObjectsPermissions,
type ObjectsPermissionsByRoleIdDeprecated,
@@ -14,10 +15,10 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
type CacheResult<T, U> = {
@@ -45,6 +46,18 @@ export class WorkspacePermissionsCacheService {
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
private readonly getRolesPermissionsFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
ObjectsPermissionsByRoleIdDeprecated
>,
private readonly getUserWorkspaceRoleMapFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
UserWorkspaceRoleMap
>,
private readonly getApiKeyRoleMapFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
Record<string, string>
>,
) {}
async recomputeRolesPermissionsCache({
@@ -109,44 +122,48 @@ export class WorkspacePermissionsCacheService {
}: {
workspaceId: string;
}): Promise<CacheResult<string, ObjectsPermissionsByRoleIdDeprecated>> {
return getFromCacheWithRecompute<
string,
ObjectsPermissionsByRoleIdDeprecated
>({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissions(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissionsVersion(
workspaceId,
),
recomputeCache: (params) => this.recomputeRolesPermissionsCache(params),
cachedEntityName: ROLES_PERMISSIONS,
exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND,
logger: this.logger,
});
return this.getRolesPermissionsFromCacheWithRecomputeService.getFromCacheWithRecompute(
{
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissions(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissionsVersion(
workspaceId,
),
recomputeCache: (params) => this.recomputeRolesPermissionsCache(params),
cachedEntityName: ROLES_PERMISSIONS,
exceptionCode:
TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND,
},
);
}
async getUserWorkspaceRoleMapFromCache({
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<undefined, UserWorkspaceRoleMap>> {
return getFromCacheWithRecompute<undefined, UserWorkspaceRoleMap>({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMap(
workspaceId,
),
recomputeCache: (params) =>
this.recomputeUserWorkspaceRoleMapCache(params),
cachedEntityName: USER_WORKSPACE_ROLE_MAP,
exceptionCode:
TwentyORMExceptionCode.USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND,
logger: this.logger,
});
}): Promise<CacheResult<string, UserWorkspaceRoleMap>> {
return this.getUserWorkspaceRoleMapFromCacheWithRecomputeService.getFromCacheWithRecompute(
{
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMap(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMapVersion(
workspaceId,
),
recomputeCache: (params) =>
this.recomputeUserWorkspaceRoleMapCache(params),
cachedEntityName: USER_WORKSPACE_ROLE_MAP,
exceptionCode:
TwentyORMExceptionCode.USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND,
},
);
}
async getRoleIdFromUserWorkspaceId({
@@ -364,18 +381,24 @@ export class WorkspacePermissionsCacheService {
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<undefined, Record<string, string>>> {
return getFromCacheWithRecompute<undefined, Record<string, string>>({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getApiKeyRoleMap(
workspaceId,
),
recomputeCache: (params) => this.recomputeApiKeyRoleMapCache(params),
cachedEntityName: 'API_KEY_ROLE_MAP',
exceptionCode: TwentyORMExceptionCode.API_KEY_ROLE_MAP_VERSION_NOT_FOUND,
logger: this.logger,
});
}): Promise<CacheResult<string, Record<string, string>>> {
return this.getApiKeyRoleMapFromCacheWithRecomputeService.getFromCacheWithRecompute(
{
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getApiKeyRoleMap(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getApiKeyRoleMapVersion(
workspaceId,
),
recomputeCache: (params) => this.recomputeApiKeyRoleMapCache(params),
cachedEntityName: 'API_KEY_ROLE_MAP',
exceptionCode:
TwentyORMExceptionCode.API_KEY_ROLE_MAP_VERSION_NOT_FOUND,
},
);
}
private async getApiKeyRoleMapFromDatabase({
@@ -25,7 +25,7 @@ import {
import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import { type CacheKey } from 'src/engine/twenty-orm/storage/types/cache-key.type';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
@@ -53,6 +53,10 @@ export class WorkspaceDatasourceFactory {
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly getFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
ObjectsPermissionsByRoleIdDeprecated
>,
) {}
private async safelyDestroyDataSource(
@@ -236,10 +240,7 @@ export class WorkspaceDatasourceFactory {
}: {
workspaceId: string;
}): Promise<CacheResult<string, ObjectsPermissionsByRoleIdDeprecated>> {
return getFromCacheWithRecompute<
string,
ObjectsPermissionsByRoleIdDeprecated
>({
return this.getFromCacheWithRecomputeService.getFromCacheWithRecompute({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissions(
@@ -255,7 +256,6 @@ export class WorkspaceDatasourceFactory {
}),
cachedEntityName: ROLES_PERMISSIONS,
exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND,
logger: this.logger,
});
}
@@ -1,85 +0,0 @@
import { type Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
TwentyORMException,
type TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
type CacheResult<T, U> = {
version: T;
data: U;
};
const getFromCacheWithRecompute = async <T, U>({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
logger,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion?: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
logger: Logger;
}): Promise<CacheResult<T, U>> => {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
const expectCacheVersion = isDefined(getCacheVersion);
if (expectCacheVersion) {
cachedVersion = await getCacheVersion(workspaceId);
}
cachedData = await getCacheData(workspaceId);
if (
!isDefined(cachedData) ||
(expectCacheVersion && !isDefined(cachedVersion))
) {
logger.warn(
`Triggering cache recompute for ${cachedEntityName} (workspace ${workspaceId})`,
{
cachedVersion,
cachedData,
},
);
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
if (expectCacheVersion) {
cachedVersion = await getCacheVersion(workspaceId);
}
if (
!isDefined(cachedData) ||
(expectCacheVersion && !isDefined(cachedVersion))
) {
logger.warn(
`Data still missing after recompute for ${cachedEntityName} (workspace ${workspaceId})`,
{
cachedVersion,
cachedData,
},
);
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId} (missingData: ${!isDefined(cachedData)}, missingVersion: ${expectCacheVersion && !isDefined(cachedVersion)})`,
exceptionCode,
);
}
}
return {
version: cachedVersion as T,
data: cachedData,
};
};
export { CacheResult, getFromCacheWithRecompute };
@@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { logger } from '@sentry/node';
import { isDefined } from 'twenty-shared/utils';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
type CacheResult<T, U> = {
version: T;
data: U;
};
@Injectable()
export class GetDataFromCacheWithRecomputeService<T, U> {
private cache = new Map<string, CacheResult<T, U>>();
logger = new Logger(GetDataFromCacheWithRecomputeService.name);
constructor() {}
getFromCacheWithRecompute = async ({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
}): Promise<CacheResult<T, U>> => {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
cachedVersion = await getCacheVersion(workspaceId);
const cacheKey = `${workspaceId}-${cachedVersion}`;
const cachedValue = this.cache.get(cacheKey);
if (cachedValue) {
return cachedValue;
}
cachedData = await getCacheData(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
logger.warn(
`Triggering cache recompute for ${cachedEntityName} (workspace ${workspaceId})`,
{
cachedVersion,
cachedData,
},
);
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
cachedVersion = await getCacheVersion(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
logger.warn(
`Data still missing after recompute for ${cachedEntityName} (workspace ${workspaceId})`,
{
cachedVersion,
cachedData,
},
);
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId} (missingData: ${!isDefined(cachedData)}, missingVersion: ${!isDefined(cachedVersion)})`,
exceptionCode,
);
}
}
this.cache.set(cacheKey, {
version: cachedVersion,
data: cachedData,
});
return {
version: cachedVersion,
data: cachedData,
};
};
}
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Module({
providers: [WorkspaceCacheStorageService],
exports: [WorkspaceCacheStorageService],
providers: [
WorkspaceCacheStorageService,
GetDataFromCacheWithRecomputeService,
],
exports: [WorkspaceCacheStorageService, GetDataFromCacheWithRecomputeService],
})
export class WorkspaceCacheStorageModule {}