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
@@ -30,7 +30,6 @@ import { WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-q
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
@@ -132,32 +131,15 @@ export abstract class CommonBaseQueryRunnerService<
commonQueryParser,
);
const isGlobalDatasourceEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED,
authContext.workspace.id,
);
if (isGlobalDatasourceEnabled) {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () =>
this.executeQueryAndEnrichResults(
processedArgs,
authContext,
queryRunnerContext,
commonQueryParser,
isGlobalDatasourceEnabled,
),
);
}
return this.executeQueryAndEnrichResults(
processedArgs,
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
queryRunnerContext,
commonQueryParser,
isGlobalDatasourceEnabled,
async () =>
this.executeQueryAndEnrichResults(
processedArgs,
authContext,
queryRunnerContext,
commonQueryParser,
),
);
}
@@ -217,17 +199,12 @@ export abstract class CommonBaseQueryRunnerService<
authContext: WorkspaceAuthContext,
queryRunnerContext: CommonBaseQueryRunnerContext,
commonQueryParser: GraphqlQueryParser,
isGlobalDatasourceEnabled: boolean,
): Promise<Output> {
const extendedQueryRunnerContext = isGlobalDatasourceEnabled
? await this.prepareExtendedQueryRunnerContextWithGlobalDatasource(
authContext,
queryRunnerContext,
)
: await this.prepareExtendedQueryRunnerContext(
authContext,
queryRunnerContext,
);
const extendedQueryRunnerContext =
await this.prepareExtendedQueryRunnerContextWithGlobalDatasource(
authContext,
queryRunnerContext,
);
const results = await this.run(processedArgs, {
...extendedQueryRunnerContext,
@@ -312,81 +289,28 @@ export abstract class CommonBaseQueryRunnerService<
}
}
private async getRoleIdAndObjectsPermissions(
private async getRoleIdOrThrow(
authContext: AuthContext,
workspaceId: string,
) {
let roleId: string;
if (
!isDefined(authContext.apiKey) &&
!isDefined(authContext.userWorkspaceId)
) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT,
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
);
}
): Promise<string> {
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKey(
return this.apiKeyRoleService.getRoleIdForApiKey(
authContext.apiKey.id,
workspaceId,
);
} else {
const userWorkspaceRoleId =
await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(userWorkspaceRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
);
}
roleId = userWorkspaceRoleId;
}
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
if (!isDefined(authContext.userWorkspaceId)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
);
}
return { roleId, objectsPermissions: rolesPermissions[roleId] };
}
private async prepareExtendedQueryRunnerContext(
authContext: WorkspaceAuthContext,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<Omit<CommonExtendedQueryRunnerContext, 'commonQueryParser'>> {
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: authContext.workspace.id,
});
const { roleId } = await this.getRoleIdAndObjectsPermissions(
authContext,
authContext.workspace.id,
);
const rolePermissionConfig = { unionOf: [roleId] };
const repository = workspaceDataSource.getRepository(
queryRunnerContext.flatObjectMetadata.nameSingular,
rolePermissionConfig,
authContext,
);
return {
...queryRunnerContext,
authContext,
workspaceDataSource,
rolePermissionConfig,
repository,
};
return this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
}
private async prepareExtendedQueryRunnerContextWithGlobalDatasource(
@@ -395,10 +319,7 @@ export abstract class CommonBaseQueryRunnerService<
): Promise<Omit<CommonExtendedQueryRunnerContext, 'commonQueryParser'>> {
const workspaceId = authContext.workspace.id;
const { roleId } = await this.getRoleIdAndObjectsPermissions(
authContext,
workspaceId,
);
const roleId = await this.getRoleIdOrThrow(authContext, workspaceId);
const rolePermissionConfig = { unionOf: [roleId] };