Add local only cache to cache service and cache typeorm entity metadata (#16287)

## Problem

buildEntityMetadatas in GlobalWorkspaceOrmManager is computationally
expensive and was running on every executeInWorkspaceContext call. This
method uses
TypeORM's EntitySchemaTransformer and EntityMetadataBuilder to build
metadata for all workspace entities (30-50+ objects with many fields
each).

The resulting EntityMetadata[] is not serialisable which means it cannot
be cached in Redis because they contain:
- Circular references
- Functions/methods
- References to the DataSource instance

## Solution

Extended the workspace cache system to support local-only caching, then
created a cache provider for entityMetadatas.

## Implementation details
Updated @WorkspaceCache decorator (workspace-cache.decorator.ts)
- Added localOnly?: boolean option to skip Redis storage for
non-serializable data
Created WorkspaceEntityMetadatasCacheService
- Computes entity metadatas from DB to avoid race condition, this is
acceptable
Simplified GlobalWorkspaceOrmManager
- Now fetches entityMetadatas from cache instead of rebuilding on every
call
Updated Workspace migration runner - the only entry point where metadata
can change
- Now invalidate the new 'entityMetadata' local cache when
shouldIncrementMetadataGraphqlSchemaVersion is true (== field/object
mutations)
This commit is contained in:
Weiko
2025-12-03 19:50:40 +01:00
committed by GitHub
parent 9c334b5f03
commit 3d95d6ca00
20 changed files with 775 additions and 533 deletions
@@ -1,13 +1,15 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type RelationType } from 'typeorm/metadata/types/RelationTypes';
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';
import {
RelationException,
RelationExceptionCode,
} from 'src/engine/twenty-orm/exceptions/relation.exception';
import {
type EntitySchemaFieldMetadata,
type EntitySchemaFieldMetadataMaps,
type EntitySchemaObjectMetadataMaps,
} from 'src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type';
import { converRelationTypeToTypeORMRelationType } from 'src/engine/twenty-orm/utils/convert-relation-type-to-typeorm-relation-type.util';
interface RelationDetails {
@@ -18,11 +20,11 @@ interface RelationDetails {
}
export function determineSchemaRelationDetails(
fieldMetadata: FlatFieldMetadata<
fieldMetadata: EntitySchemaFieldMetadata<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
objectMetadataMaps: EntitySchemaObjectMetadataMaps,
fieldMetadataMaps: EntitySchemaFieldMetadataMaps,
): RelationDetails {
if (!fieldMetadata.settings) {
throw new Error('Field metadata settings are missing');
@@ -39,12 +41,10 @@ export function determineSchemaRelationDetails(
);
}
const sourceObjectMetadata =
flatObjectMetadataMaps.byId[fieldMetadata.objectMetadataId];
const targetObjectMetadata =
flatObjectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId];
if (!sourceObjectMetadata || !targetObjectMetadata) {
if (!targetObjectMetadata) {
throw new RelationException(
`Object metadata not found for field ${fieldMetadata.id}`,
RelationExceptionCode.RELATION_OBJECT_METADATA_NOT_FOUND,
@@ -59,7 +59,7 @@ export function determineSchemaRelationDetails(
}
const targetFieldMetadata =
flatFieldMetadataMaps.byId[fieldMetadata.relationTargetFieldMetadataId];
fieldMetadataMaps.byId[fieldMetadata.relationTargetFieldMetadataId];
if (!targetFieldMetadata) {
throw new Error('Target field metadata not found');