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
@@ -2,7 +2,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { IsNull, Not, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -15,7 +15,7 @@ import {
LogicFunctionTriggerJob,
LogicFunctionTriggerJobData,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { shouldRunNow } from 'src/utils/should-run-now.utils';
export const CRON_TRIGGER_CRON_PATTERN = '* * * * *';
@@ -27,8 +27,7 @@ export class CronTriggerCronJob {
private readonly messageQueueService: MessageQueueService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Process(CronTriggerCronJob.name)
@@ -44,22 +43,30 @@ export class CronTriggerCronJob {
const now = new Date();
for (const activeWorkspace of activeWorkspaces) {
const logicFunctionsWithCronTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: activeWorkspace.id,
cronTriggerSettings: Not(IsNull()),
},
select: ['id', 'cronTriggerSettings', 'workspaceId'],
});
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(activeWorkspace.id, [
'flatLogicFunctionMaps',
]);
const logicFunctions = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
);
for (const logicFunction of logicFunctions) {
if (!isDefined(logicFunction)) {
continue;
}
for (const logicFunction of logicFunctionsWithCronTrigger) {
const cronSettings = logicFunction.cronTriggerSettings;
if (!isDefined(cronSettings?.pattern)) {
continue;
}
if (isDefined(logicFunction.deletedAt)) {
continue;
}
if (!shouldRunNow(cronSettings.pattern, now)) {
continue;
}
@@ -69,7 +76,7 @@ export class CronTriggerCronJob {
[
{
logicFunctionId: logicFunction.id,
workspaceId: logicFunction.workspaceId,
workspaceId: activeWorkspace.id,
payload: {},
},
],