Skip to content

feat: optimize hot database queries with multi-layer caching#19068

Merged
charlesBochet merged 23 commits intomainfrom
optimize-hot-db-queries
Mar 28, 2026
Merged

feat: optimize hot database queries with multi-layer caching#19068
charlesBochet merged 23 commits intomainfrom
optimize-hot-db-queries

Conversation

@charlesBochet
Copy link
Copy Markdown
Member

@charlesBochet charlesBochet commented Mar 28, 2026

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)

Introduces CoreEntityCacheService for non-workspace-scoped entities
(Workspace, User, UserWorkspace) and leverages WorkspaceCacheService
for workspace-scoped entities (ApiKey, LogicFunction) to dramatically
reduce database load on the authentication hot path.

Key changes:
- New CoreEntityCacheService with provider pattern (in-process + Redis)
  mirroring WorkspaceCacheService but keyed by entity primary key
- Three CoreEntityCache providers: WorkspaceEntity, AuthContextUser,
  UserWorkspaceEntity
- New apiKeyMap WorkspaceCache provider for API key lookups
- JwtAuthStrategy refactored to use caches instead of direct DB queries
- CronTriggerCronJob refactored to use flatLogicFunctionMaps cache
- Cache invalidation hooks in WorkspaceService and ApiKeyService

Expected impact:
- WorkspaceEntity queries: 638/min → near zero (cached)
- ApiKeyEntity queries: 491/min → near zero (cached per workspace)
- UserEntity queries: 147/min → near zero (cached)
- UserWorkspaceEntity queries: 143/min → near zero (cached)
- LogicFunctionEntity queries: 1800/min → near zero (workspace cache)

Made-with: Cursor
Copilot AI review requested due to automatic review settings March 28, 2026 11:04
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds multi-layer caching to reduce high-frequency DB queries on the JWT auth hot path and cron-trigger logic execution, leveraging Redis-backed validation plus short in-process caching.

Changes:

  • Introduces a new CoreEntityCacheService (local + Redis) with provider/decorator pattern for core (non-workspace-scoped) entity lookups.
  • Adds a workspace-scoped apiKeyMap cache and wires cache invalidation into API key mutations.
  • Refactors JwtAuthStrategy and CronTriggerCronJob to use caches instead of direct repository queries.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts Adds apiKeyMap to workspace cache key/type map.
packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts Registers core-entity cache module/provider in workspace module.
packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts Invalidates core-entity cache on workspace mutations/deletes.
packages/twenty-server/src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service.ts Adds cache provider for workspace lookups.
packages/twenty-server/src/engine/core-modules/user/user.module.ts Registers user cache provider.
packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts Adds cache provider for AuthContextUser projection.
packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts Registers user-workspace cache provider.
packages/twenty-server/src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service.ts Adds cache provider for user-workspace lookups.
packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job.ts Switches cron logic-function lookup from DB to cached flat maps.
packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts Adds EngineCoreEntity cache namespace.
packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts Imports CoreEntityCacheModule and removes unused ApiKeyEntity repo.
packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts Replaces Workspace/User/UserWorkspace/ApiKey repository lookups with caches.
packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts Updates strategy tests to mock the new cache-based dependencies.
packages/twenty-server/src/engine/core-modules/auth/auth.module.ts Imports CoreEntityCacheModule for auth module wiring.
packages/twenty-server/src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service.ts New workspace cache provider loading API keys into an ID-keyed map.
packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts Invalidates/recomputes apiKeyMap cache on create/update/revoke.
packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts Registers the new WorkspaceApiKeyMapCacheService.
packages/twenty-server/src/engine/core-entity-cache/types/core-entity-local-cache-entry.type.ts Defines local-cache entry/version types for core-entity caching.
packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts Defines cache key names + type map for core entities.
packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts Implements local+Redis core-entity caching with hash validation + memoization.
packages/twenty-server/src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service.ts Defines core-entity cache provider abstraction.
packages/twenty-server/src/engine/core-entity-cache/exceptions/core-entity-cache.exception.ts Adds domain exception type for core-entity cache.
packages/twenty-server/src/engine/core-entity-cache/decorators/core-entity-cache.decorator.ts Adds @CoreEntityCache provider registration decorator.
packages/twenty-server/src/engine/core-entity-cache/core-entity-cache.module.ts Exposes core-entity cache service via a dedicated module.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

The ApiKeyService now depends on WorkspaceCacheService for cache
invalidation, so its test module needs to provide a mock.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts:92">
P1: Throwing `CoreEntityCacheException` for an invalid UUID in the auth hot path will surface as `INTERNAL_SERVER_ERROR` to clients, since this exception type is not mapped by auth exception handlers. When the entity ID comes from an untrusted JWT payload, a malformed token should result in an `UNAUTHENTICATED` error. Return `null` here instead (or catch and rethrow as `AuthException` in the caller) so the existing auth flow handles it gracefully.</violation>

<violation number="2" location="packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts:168">
P2: Race condition: `invalidate` is missing a second `memoizer.clearKeys` after `flush`. A concurrent `get()` can re-populate the memoizer with stale data between the first `clearKeys` and the `flush`, leaving stale values cached for up to 10 seconds. `invalidateAndRecompute` already guards against this with a trailing `clearKeys`—apply the same pattern here.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts:295">
P1: Do not let cache invalidation failure abort activation after setting `ONGOING_CREATION`; this can leave workspaces stuck in an unretryable state.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@FelixMalfait
Copy link
Copy Markdown
Member

FelixMalfait commented Mar 28, 2026

🚀 Preview Environment Ready!

Your preview environment is available at: http://bore.pub:52203

This environment will automatically shut down after 5 hours.

When users are soft-deleted or userWorkspaces are removed, the
CoreEntityCacheService must be invalidated to prevent stale cache
entries from allowing authentication to succeed for deleted entities.

Also adds missing CoreEntityCacheService mock in workspace.service.spec.

Made-with: Cursor
- Return null instead of throwing for invalid UUID in get() to prevent
  500 errors when auth tokens contain malformed entity IDs
- Add second memoizer.clearKeys after flush in invalidate() to prevent
  race condition where concurrent get() could repopulate stale data

Made-with: Cursor
UserService now depends on CoreEntityCacheService for cache invalidation
on user deletion, so the test module needs the mock provider.

Made-with: Cursor
Replace raw TypeORM entities in cache/auth context with serialization-safe
flat types (Date fields as ISO strings, relations stripped). This prevents
Redis JSON round-trip issues where Date objects deserialize as strings.

- Add FlatWorkspaceEntity, FlatAuthContextUser, FlatUserWorkspaceEntity,
  FlatApiKeyEntity types and from-*-to-flat conversion utilities
- Update cache providers to convert entities before storing
- Update RawAuthContext, GraphQLContext, Express Request to use flat types
- Fix all consumers that called .toISOString() on already-string dates
- Align test mocks with flat entity types

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 42 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants.ts:17">
P1: Avoid caching `passwordHash` in auth context user payloads; this unnecessarily propagates sensitive credential material into shared cache layers.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Entity suffix is reserved for TypeORM entities. Rename cached flat types:
- FlatWorkspaceEntity → FlatWorkspace
- FlatApiKeyEntity → FlatApiKey
- FlatUserWorkspaceEntity → FlatUserWorkspace

Made-with: Cursor
Tests for UserWorkspaceService and WorkspaceAgnosticTokenService now
include required date fields so fromAuthContextUserToFlat doesn't fail.

Made-with: Cursor
CI typechecker (tsc) is stricter than tsgo about type cast overlap.

Made-with: Cursor
Flat types are a general serialization concept, not tied to caching.
Move each flat type and conversion util to its entity's module:
- FlatWorkspace → core-modules/workspace/types/
- FlatApiKey → core-modules/api-key/types/
- FlatUserWorkspace → core-modules/user-workspace/types/
- FlatAuthContextUser → core-modules/auth/types/

Made-with: Cursor
…ve passwordHash

- Bind FlatWorkspace to WorkspaceEntity via Omit pattern (like FlatApplication)
  instead of manually listing fields
- Remove passwordHash from AUTH_CONTEXT_USER_SELECT_FIELDS (query DB when needed)
- Introduce FlatUser type cached directly; derive FlatAuthContextUser as Pick<FlatUser>
- Replace fromAuthContextUserToFlat with fromUserEntityToFlat everywhere

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 17 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts:92">
P1: Using `fromUserEntityToFlat` here injects `passwordHash` into auth context user data. Return an auth-scoped user shape that excludes sensitive fields.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts:31">
P1: This change caches `passwordHash` in the `authContextUser` cache payload, exposing sensitive credential data in shared cache storage.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts:7">
P2: `authContextUser` is typed as full `FlatUser`, which exposes sensitive fields (e.g. `passwordHash`) through the auth cache contract. Keep this key typed to the restricted auth-context user shape.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Write paths (sign-up, onboarding, workspace member creation) now use
fresh UserEntity from the database directly instead of converting to
flat entities. Flat conversion only remains at caching boundaries.

- ExistingUserOrPartialUserWithPicture.existingUser reverted to UserEntity
- activateOnboardingForUser accepts Pick<UserEntity, ...> instead of AuthContextUser
- createWorkspaceMember accepts Pick<UserEntity, ...> instead of AuthContextUser
- setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch accepts Pick<UserEntity, 'email'>
- signUpInNewWorkspace fetches fresh UserEntity from DB before write operations

Made-with: Cursor
…ty types

Drop ScalarFlatEntity for core entities. All flat types now use the same
pattern: explicit NON_COLUMN_PROPERTIES constant + Omit + date cast.

- Rename RELATION_PROPERTIES to NON_COLUMN_PROPERTIES (accurate: includes
  virtual fields, methods, DTOs — not just TypeORM relations)
- FlatApiKey: explicit exclusion replacing ScalarFlatEntity<ApiKeyEntity>
- FlatUserWorkspace: explicit exclusion replacing ScalarFlatEntity<UserWorkspaceEntity>
  (ScalarFlatEntity missed virtual fields like permissionFlags, objectPermissions)

Made-with: Cursor
…cache

The exclusion lists define what should NOT be cached, not just what's not
a column. Rename to NON_CACHED_PROPERTIES to reflect actual intent.

Add passwordHash to USER_ENTITY_NON_CACHED_PROPERTIES — sensitive data
should never be stored in Redis cache.

Made-with: Cursor
Made-with: Cursor
- Add expiresAt validation in validateAPIKey (was only checking revokedAt,
  allowing expired keys to authenticate)
- Make cache invalidation fire-and-forget in workspace service to prevent
  invalidation failures from aborting workspace activation/updates

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts:295">
P1: Do not fire-and-forget workspace cache invalidation after writes; await it to preserve read-after-write consistency and avoid silently serving stale workspace data.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

The hardcoded '2025-12-31T23:59:59.000Z' expiry date is now in the
past, causing all integration tests using API key auth to fail with
"This API Key is expired" after the expiresAt validation was added.

Made-with: Cursor
…stency

Reverts fire-and-forget pattern for CoreEntityCacheService.invalidate
calls in workspace.service.ts. Awaiting ensures subsequent reads see
fresh data instead of stale cached values.

Made-with: Cursor
Since we now cache the full FlatUser (not just FlatAuthContextUser),
the cache key should reflect the actual entity being cached.

Made-with: Cursor
Comment on lines 201 to 207

if (user.userWorkspaces.length === 1) {
await this.userRepository.softDelete(userId);
await this.coreEntityCacheService.invalidate('user', userId);
}

return userWorkspace;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Removing a user via UserService.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace fails to invalidate the userWorkspaceEntity cache, allowing the removed user to retain access via the stale cache entry.
Severity: HIGH

Suggested Fix

After the call to this.userWorkspaceService.deleteUserWorkspace in user.service.ts, add a cache invalidation call similar to the one in workspace.service.ts: await this.coreEntityCacheService.invalidate('userWorkspaceEntity', userWorkspaceId). This will ensure the cache is cleared immediately after the user-workspace link is soft-deleted.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
packages/twenty-server/src/engine/core-modules/user/services/user.service.ts#L201-L207

Potential issue: When a user is removed from a workspace via
`UserService.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace`, the associated
`userWorkspaceEntity` cache entry is not invalidated. The authentication logic in
`JwtAuthStrategy.validateAccessToken` retrieves this stale, soft-deleted entry from the
cache but does not check the `deletedAt` field. This allows the removed user to maintain
access to the workspace for up to 30 minutes, which is the local cache's time-to-live.
The correct behavior, which includes cache invalidation, is already implemented in
`WorkspaceService.handleRemoveWorkspaceMember`, but it is missing from the `UserService`
code path.

When WorkspaceMember locale is updated, the pre-query hook also
updates UserWorkspaceEntity.locale in the DB but was not invalidating
the userWorkspaceEntity cache. Subsequent requests would serve the
stale cached locale, causing i18n metadata queries to return labels
in the wrong language.

Made-with: Cursor
Made-with: Cursor
@charlesBochet charlesBochet merged commit c407341 into main Mar 28, 2026
80 checks passed
@charlesBochet charlesBochet deleted the optimize-hot-db-queries branch March 28, 2026 21:53
@twenty-eng-sync
Copy link
Copy Markdown

Hey @charlesBochet! After you've done the QA of your Pull Request, you can mark it as done here. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants