From bd8ed03990fa2edab186f4be8f56de3ed663ae4b Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 12 Dec 2025 10:14:46 +0100 Subject: [PATCH] Add TTL eviction to local data cache (#16510) We keep different versions of our cache to avoid race conditions but we never evict stale data. This PR should fix that --- .../common-base-query-runner.service.ts | 1 - ...pace-orm-entity-metadatas-cache.service.ts | 16 +++++---- .../services/workspace-cache.service.ts | 33 ++++++++++++++++--- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index 17c80a335d..1782e6b057 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -3,7 +3,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { msg } from '@lingui/core/macro'; import { type PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; -import { Omit } from 'zod/v4/core/util.cjs'; import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value'; diff --git a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/workspace-orm-entity-metadatas-cache.service.ts b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/workspace-orm-entity-metadatas-cache.service.ts index c6aa27188d..39024fb3a8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/workspace-orm-entity-metadatas-cache.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/workspace-orm-entity-metadatas-cache.service.ts @@ -11,7 +11,7 @@ import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/wo import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; -import { GlobalWorkspaceDataSourceService } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildEntitySchemaMetadataMaps } from 'src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type'; import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; @@ -26,7 +26,7 @@ export class WorkspaceORMEntityMetadatasCacheService extends WorkspaceCacheProvi @InjectRepository(FieldMetadataEntity) private readonly fieldMetadataRepository: Repository, private readonly entitySchemaFactory: EntitySchemaFactory, - private readonly globalWorkspaceDataSourceService: GlobalWorkspaceDataSourceService, + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, ) { super(); } @@ -57,17 +57,21 @@ export class WorkspaceORMEntityMetadatasCacheService extends WorkspaceCacheProvi ), ); - return this.buildEntityMetadatas(entitySchemas); + const entityMetadatas = await this.buildEntityMetadatas(entitySchemas); + + return entityMetadatas; } - private buildEntityMetadatas( + private async buildEntityMetadatas( entitySchemas: EntitySchema[], - ): EntityMetadata[] { + ): Promise { const transformer = new EntitySchemaTransformer(); const metadataArgsStorage = transformer.transform(entitySchemas); + const dataSource = + await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource(); const entityMetadataBuilder = new EntityMetadataBuilder( - this.globalWorkspaceDataSourceService.getGlobalWorkspaceDataSource(), + dataSource, metadataArgsStorage, ); diff --git a/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts b/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts index a64a84dedd..f3610494ed 100644 --- a/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts @@ -28,10 +28,11 @@ import { } from 'src/engine/workspace-cache/types/workspace-cache-key.type'; import { type WorkspaceLocalCacheEntry } from 'src/engine/workspace-cache/types/workspace-local-cache-entry.type'; -const LOCAL_TTL_MS = 100; -const MEMOIZER_TTL_MS = 10_000; -const STALE_VERSION_TTL_MS = 5_000; -const MAX_LOCAL_STALE_VERSIONS = 5; +const LOCAL_TTL_MS = 100; // 100ms +const LOCAL_ENTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes +const MEMOIZER_TTL_MS = 10_000; // 10 seconds +const STALE_VERSION_TTL_MS = 5_000; // 5 seconds +const MAX_LOCAL_STALE_VERSIONS = 5; // 5 stale versions type CacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName]; @@ -97,6 +98,8 @@ export class WorkspaceCacheService implements OnModuleInit { workspaceId: string, cacheKeyNames: K, ): Promise> { + this.evictExpiredLocalEntries(); + if ( !isDefined(workspaceId) || cacheKeyNames.length === 0 || @@ -422,6 +425,28 @@ export class WorkspaceCacheService implements OnModuleInit { } } + private evictExpiredLocalEntries(): void { + const now = Date.now(); + + for (const [localKey, entry] of this.localCache) { + for (const [hash, version] of entry.versions) { + if (now - version.lastReadAt > LOCAL_ENTRY_TTL_MS) { + entry.versions.delete(hash); + } + } + + if (entry.versions.size === 0) { + this.localCache.delete(localKey); + continue; + } + + if (!entry.versions.has(entry.latestHash)) { + // Latest was evicted; drop the entire entry to avoid serving stale data. + this.localCache.delete(localKey); + } + } + } + private getProviderOrThrow( keyName: WorkspaceCacheKeyName, ): WorkspaceCacheProvider {