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:
Charles Bochet
2026-03-28 22:53:34 +01:00
committed by GitHub
parent 81fc960712
commit c407341912
69 changed files with 1423 additions and 554 deletions
@@ -6,6 +6,7 @@ import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { WorkspaceApiKeyMapCacheService } from 'src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -39,6 +40,7 @@ import { ApiKeyController } from './controllers/api-key.controller';
ApiKeyService,
ApiKeyResolver,
ApiKeyRoleService,
WorkspaceApiKeyMapCacheService,
GenerateApiKeyCommand,
],
controllers: [ApiKeyController],
@@ -0,0 +1,5 @@
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
export const API_KEY_ENTITY_NON_CACHED_PROPERTIES = [
'workspace',
] as const satisfies ReadonlyArray<keyof ApiKeyEntity>;
@@ -14,6 +14,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-contex
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
@@ -111,6 +112,12 @@ describe('ApiKeyService', () => {
provide: getDataSourceToken(),
useValue: mockDataSource,
},
{
provide: WorkspaceCacheService,
useValue: {
invalidateAndRecompute: jest.fn(),
},
},
],
}).compile();
@@ -14,6 +14,7 @@ import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class ApiKeyService {
@@ -22,6 +23,7 @@ export class ApiKeyService {
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly jwtWrapperService: JwtWrapperService,
private readonly roleTargetService: RoleTargetService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async create(
@@ -44,6 +46,8 @@ export class ApiKeyService {
throw error;
}
await this.invalidateApiKeyCache(savedApiKey.workspaceId);
return savedApiKey;
}
@@ -88,6 +92,7 @@ export class ApiKeyService {
}
await this.apiKeyRepository.update(id, updateData);
await this.invalidateApiKeyCache(workspaceId);
return this.findById(id, workspaceId);
}
@@ -184,4 +189,10 @@ export class ApiKeyService {
isActive(apiKey: ApiKeyEntity): boolean {
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
}
private async invalidateApiKeyCache(workspaceId: string): Promise<void> {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'apiKeyMap',
]);
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { fromApiKeyEntityToFlat } from 'src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@Injectable()
@WorkspaceCache('apiKeyMap')
export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
Record<string, FlatApiKey>
> {
constructor(
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<Record<string, FlatApiKey>> {
const apiKeys = await this.apiKeyRepository.find({
where: { workspaceId },
});
return apiKeys.reduce(
(map, apiKey) => {
map[apiKey.id] = fromApiKeyEntityToFlat(apiKey);
return map;
},
{} as Record<string, FlatApiKey>,
);
}
}
@@ -0,0 +1,14 @@
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
import { type API_KEY_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
type ApiKeyEntityNonCachedProperties =
(typeof API_KEY_ENTITY_NON_CACHED_PROPERTIES)[number];
type ApiKeyCachedFields = Omit<ApiKeyEntity, ApiKeyEntityNonCachedProperties>;
export type FlatApiKey = Omit<
ApiKeyCachedFields,
keyof CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>
> &
CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>;
@@ -0,0 +1,12 @@
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';
export const fromApiKeyEntityToFlat = (entity: ApiKeyEntity): FlatApiKey => ({
id: entity.id,
name: entity.name,
workspaceId: entity.workspaceId,
expiresAt: entity.expiresAt.toISOString(),
revokedAt: entity.revokedAt?.toISOString() ?? null,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
});