feat: optimize hot database queries with multi-layer caching (#19068)
## Summary Introduces multi-layer caching for the 5 most frequent database queries identified in production (Sentry data), targeting the JWT authentication hot path and cron job logic. ### Problem Our database is under heavy load from uncached queries on the auth hot path: - `WorkspaceEntity` lookups: **638 queries/min** - `ApiKeyEntity` lookups: **491 queries/min** - `UserEntity` lookups: **147 queries/min** - `UserWorkspaceEntity` lookups: **143 queries/min** - `LogicFunctionEntity` lookups: **1800 queries/min** (cron job) ### Solution **1. New `CoreEntityCacheService`** for non-workspace-scoped entities (Workspace, User, UserWorkspace): - Mirrors `WorkspaceCacheService` architecture (in-process Map + Redis with hash validation) - Provider pattern with `@CoreEntityCache` decorator - Keyed by entity primary key (not workspaceId) - 100ms local TTL, Redis-backed hash validation for cross-instance consistency - Three providers: `WorkspaceEntityCacheProviderService`, `UserEntityCacheProviderService`, `UserWorkspaceEntityCacheProviderService` **2. New `apiKeyMap` WorkspaceCache** for workspace-scoped API key lookups: - `WorkspaceApiKeyMapCacheService` loads all API keys for a workspace into a map by ID - Leverages existing `WorkspaceCacheService` infrastructure - Cache invalidation on API key create/update/revoke **3. `CronTriggerCronJob` refactored** to use existing `flatLogicFunctionMaps` workspace cache: - Eliminates per-workspace `LogicFunctionEntity` repository queries (~1800/min) - Filters cached data in-memory instead **4. `JwtAuthStrategy` refactored** to use caches for all entity lookups: - Workspace, User, UserWorkspace → `CoreEntityCacheService` - ApiKey → `WorkspaceCacheService` (`apiKeyMap`) - Impersonation queries kept as direct DB queries (rare path, requires relations) **5. Cache invalidation** wired into mutation paths: - `WorkspaceService` → invalidates `workspaceEntity` on save/update/delete - `ApiKeyService` → invalidates `apiKeyMap` on create/update/revoke ### Architecture ``` Request → JwtAuthStrategy ├── Workspace lookup → CoreEntityCacheService (in-process → Redis → DB) ├── User lookup → CoreEntityCacheService (in-process → Redis → DB) ├── UserWorkspace lookup → CoreEntityCacheService (in-process → Redis → DB) └── ApiKey lookup → WorkspaceCacheService (in-process → Redis → DB) CronTriggerCronJob └── LogicFunction lookup → WorkspaceCacheService (flatLogicFunctionMaps) ``` ### Expected Impact | Query | Before | After | |-------|--------|-------| | WorkspaceEntity | 638/min | ~0 (cached) | | ApiKeyEntity | 491/min | ~0 (cached) | | UserEntity | 147/min | ~0 (cached) | | UserWorkspaceEntity | 143/min | ~0 (cached) | | LogicFunctionEntity | 1800/min | ~0 (cached) | ### Not included (ongoing separately) - DataSourceEntity query optimization (IS_DATASOURCE_MIGRATED migration) - ObjectMetadataEntity query optimization (already partially cached)
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@ import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
|
||||
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
@@ -30,7 +30,7 @@ describe('DataArgProcessorService', () => {
|
||||
|
||||
const createMockAuthContext = (): SystemWorkspaceAuthContext => ({
|
||||
type: 'system',
|
||||
workspace: { id: mockWorkspaceId } as WorkspaceEntity,
|
||||
workspace: { id: mockWorkspaceId } as FlatWorkspace,
|
||||
});
|
||||
|
||||
const createFlatFieldMetadataMaps = (
|
||||
|
||||
+5
-5
@@ -32,15 +32,15 @@ import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graph
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataloaderService } from 'src/engine/dataloaders/dataloader.service';
|
||||
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||
import { renderApolloPlayground } from 'src/engine/utils/render-apollo-playground.util';
|
||||
|
||||
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
|
||||
user?: UserEntity;
|
||||
workspace?: WorkspaceEntity;
|
||||
user?: FlatAuthContextUser;
|
||||
workspace?: FlatWorkspace;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -172,7 +172,7 @@ export class GraphQLConfigService
|
||||
|
||||
async createSchema(
|
||||
context: YogaDriverServerContext<'express'> & YogaInitialContext,
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<GraphQLSchemaWithContext<YogaDriverServerContext<'express'>>> {
|
||||
// Create a new contextId for each request
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { type YogaDriverServerContext } from '@graphql-yoga/nestjs';
|
||||
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
|
||||
user?: UserEntity;
|
||||
workspace?: WorkspaceEntity;
|
||||
user?: FlatAuthContextUser;
|
||||
workspace?: FlatWorkspace;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/work
|
||||
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
|
||||
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
@@ -41,7 +41,7 @@ export class WorkspaceSchemaFactory {
|
||||
) {}
|
||||
|
||||
async createGraphQLSchema(
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<GraphQLSchema> {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
|
||||
+4
-4
@@ -9,12 +9,12 @@ import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.contr
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
@@ -69,10 +69,10 @@ describe('McpCoreController', () => {
|
||||
});
|
||||
|
||||
describe('handleMcpCore', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
const mockUser = { id: 'user-1' } as UserEntity;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockApiKey = { id: 'api-key-1' } as ApiKeyEntity;
|
||||
const mockApiKey = { id: 'api-key-1' } as FlatApiKey;
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
} as unknown as import('express').Response;
|
||||
|
||||
@@ -18,9 +18,9 @@ import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
@@ -45,8 +45,8 @@ export class McpCoreController {
|
||||
)
|
||||
async handleMcpCore(
|
||||
@Body() body: JsonRpc,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@AuthApiKey() apiKey: FlatApiKey | undefined,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
|
||||
+4
-4
@@ -8,14 +8,14 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
|
||||
import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool';
|
||||
import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
|
||||
@@ -26,14 +26,14 @@ describe('McpProtocolService', () => {
|
||||
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
|
||||
let apiKeyRoleService: jest.Mocked<ApiKeyRoleService>;
|
||||
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockRoleId = 'role-1';
|
||||
const mockAdminRoleId = 'admin-role-1';
|
||||
const mockApiKey = {
|
||||
id: 'api-key-1',
|
||||
workspaceId: mockWorkspace.id,
|
||||
} as ApiKeyEntity;
|
||||
} as FlatApiKey;
|
||||
|
||||
const EXPECTED_MCP_TOOL_NAMES = [
|
||||
GET_TOOL_CATALOG_TOOL_NAME,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
LOAD_SKILL_TOOL_NAME,
|
||||
loadSkillInputSchema,
|
||||
} from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
|
||||
@@ -70,7 +70,7 @@ export class McpProtocolService {
|
||||
async getRoleId(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
apiKey?: ApiKeyEntity,
|
||||
apiKey?: FlatApiKey,
|
||||
) {
|
||||
if (isDefined(apiKey)) {
|
||||
return this.apiKeyRoleService.getRoleIdForApiKeyId(
|
||||
@@ -99,7 +99,7 @@ export class McpProtocolService {
|
||||
}
|
||||
|
||||
private async buildMcpToolSet(
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
roleId: string,
|
||||
options?: {
|
||||
authContext?: WorkspaceAuthContext;
|
||||
@@ -165,10 +165,10 @@ export class McpProtocolService {
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
}: {
|
||||
workspace: WorkspaceEntity;
|
||||
workspace: FlatWorkspace;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
apiKey: ApiKeyEntity | undefined;
|
||||
apiKey: FlatApiKey | undefined;
|
||||
},
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user