c407341912
## 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)
250 lines
8.9 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|