Files
twenty/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
T
Charles Bochet c407341912 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)
2026-03-28 22:53:34 +01:00

250 lines
8.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { isNonEmptyString } from '@sniptt/guards';
import { GraphQLSchema, printSchema } from 'graphql';
import { gql } from 'graphql-tag';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
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 { 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,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
@Injectable()
export class WorkspaceSchemaFactory {
constructor(
private readonly scalarsExplorerService: ScalarsExplorerService,
private readonly workspaceGraphQLSchemaGenerator: WorkspaceGraphQLSchemaGenerator,
private readonly workspaceResolverFactory: WorkspaceResolverFactory,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly featureFlagService: FeatureFlagService,
private readonly dataSourceService: DataSourceService,
) {}
async createGraphQLSchema(
workspace: FlatWorkspace,
applicationId?: string,
): Promise<GraphQLSchema> {
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
workspace.id,
);
const hasSchema = isDataSourceMigrated
? isNonEmptyString(workspace.databaseSchema)
: (
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
workspace.id,
)
).length > 0;
if (!hasSchema) {
return new GraphQLSchema({});
}
const {
flatObjectMetadataMaps: allFlatObjectMetadataMaps,
flatFieldMetadataMaps: allFlatFieldMetadataMaps,
flatIndexMaps: allFlatIndexMaps,
flatApplicationMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: workspace.id,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatIndexMaps',
'flatApplicationMaps',
],
},
);
if (!isDefined(allFlatObjectMetadataMaps)) {
throw new FlatEntityMapsException(
'Object metadata collection not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
if (!isDefined(allFlatFieldMetadataMaps)) {
throw new FlatEntityMapsException(
'Field metadata collection not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
let flatObjectMetadataMaps = allFlatObjectMetadataMaps;
let flatFieldMetadataMaps = allFlatFieldMetadataMaps;
let flatIndexMaps = allFlatIndexMaps;
if (isDefined(applicationId)) {
const twentyStandardApplicationId =
flatApplicationMaps?.idByUniversalIdentifier[
TWENTY_STANDARD_APPLICATION.universalIdentifier
];
const applicationIds = isDefined(twentyStandardApplicationId)
? [twentyStandardApplicationId, applicationId]
: [applicationId];
flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatObjectMetadataMaps,
applicationIds,
);
flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatFieldMetadataMaps,
applicationIds,
);
flatObjectMetadataMaps =
this.reconcileObjectFieldIdsWithFilteredFieldMaps(
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
if (isDefined(allFlatIndexMaps)) {
flatIndexMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatIndexMaps,
applicationIds,
);
}
}
let metadataVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (!isDefined(metadataVersion)) {
metadataVersion = isDefined(workspace.metadataVersion)
? workspace.metadataVersion
: 0;
await this.workspaceCacheStorageService.setMetadataVersion(
workspace.id,
metadataVersion,
);
}
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs(
workspace.id,
metadataVersion,
applicationId,
);
let usedScalarNames =
await this.workspaceCacheStorageService.getGraphQLUsedScalarNames(
workspace.id,
metadataVersion,
applicationId,
);
if (!typeDefs || !usedScalarNames) {
const autoGeneratedSchema =
await this.workspaceGraphQLSchemaGenerator.generateSchema({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
});
usedScalarNames =
this.scalarsExplorerService.getUsedScalarNames(autoGeneratedSchema);
typeDefs = printSchema(autoGeneratedSchema);
await this.workspaceCacheStorageService.setGraphQLTypeDefs(
workspace.id,
metadataVersion,
typeDefs,
applicationId,
);
await this.workspaceCacheStorageService.setGraphQLUsedScalarNames(
workspace.id,
metadataVersion,
usedScalarNames,
applicationId,
);
}
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
flatObjectMetadataMaps,
flatFieldMetadataMaps,
idByNameSingular,
workspaceResolverBuilderMethodNames,
);
const scalarsResolvers =
this.scalarsExplorerService.getScalarResolvers(usedScalarNames);
const executableSchema = makeExecutableSchema({
typeDefs: gql`
${typeDefs}
`,
resolvers: {
...scalarsResolvers,
...autoGeneratedResolvers,
},
});
return executableSchema;
}
private reconcileObjectFieldIdsWithFilteredFieldMaps(
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): FlatEntityMaps<FlatObjectMetadata> {
const filteredFieldIds = new Set(
Object.keys(flatFieldMetadataMaps.universalIdentifierById),
);
const reconciledByUniversalIdentifier: Partial<
Record<string, FlatObjectMetadata>
> = {};
for (const [universalId, object] of Object.entries(
flatObjectMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(object)) continue;
reconciledByUniversalIdentifier[universalId] = {
...object,
fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)),
};
}
return {
...flatObjectMetadataMaps,
byUniversalIdentifier: reconciledByUniversalIdentifier,
};
}
private filterFlatEntityMapsByApplicationIds<
T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata,
>(
flatEntityMaps: FlatEntityMaps<T>,
applicationIds: string[],
): FlatEntityMaps<T> {
return getSubFlatEntityMapsByApplicationIdsOrThrow({
applicationIds,
flatEntityMaps,
});
}
}