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
@@ -2,7 +2,19 @@ import { SetMetadata } from '@nestjs/common';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
export const WORKSPACE_CACHE_KEY = 'WORKSPACE_CACHE_KEY';
export type WorkspaceCacheOptions = {
localDataOnly?: boolean;
};
export const WorkspaceCache = (workspaceCacheKeyName: WorkspaceCacheKeyName) =>
SetMetadata(WORKSPACE_CACHE_KEY, workspaceCacheKeyName);
export const WORKSPACE_CACHE_KEY = 'WORKSPACE_CACHE_KEY';
export const WORKSPACE_CACHE_OPTIONS = 'WORKSPACE_CACHE_OPTIONS';
export const WorkspaceCache = (
workspaceCacheKeyName: WorkspaceCacheKeyName,
options?: WorkspaceCacheOptions,
): ClassDecorator => {
return (target) => {
SetMetadata(WORKSPACE_CACHE_KEY, workspaceCacheKeyName)(target);
SetMetadata(WORKSPACE_CACHE_OPTIONS, options ?? {})(target);
};
};
@@ -164,10 +164,13 @@ describe('WorkspaceCacheService', () => {
it('should return data from redis when available', async () => {
const cachedData = { FLAG_A: true, FLAG_B: false };
const cachedHash = 'some-hash-from-redis';
cacheStorageService.mget
// First call: validateLocalHashAgainstRedisHash checks hash
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([cachedData]);
// Second call: fetchDataFromRedis fetches data and hash atomically
.mockResolvedValueOnce([cachedData, cachedHash]);
const result = await service.getOrRecompute(WORKSPACE_ID, [
'featureFlagsMap',
@@ -223,7 +226,9 @@ describe('WorkspaceCacheService', () => {
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Each getOrRecompute call triggers 2 mget calls (hash check + data fetch)
// Verify Redis was rechecked after TTL expired
// Each getOrRecompute triggers: 1 hash check + 1 atomic data/hash fetch = 2 mget calls
// Total: 4 mget calls (2 per getOrRecompute)
expect(cacheStorageService.mget).toHaveBeenCalledTimes(4);
});
});
@@ -248,7 +253,7 @@ describe('WorkspaceCacheService', () => {
await service.onModuleInit();
});
it('should delete from redis and local cache, then recompute', async () => {
it('should delete from redis, mark local cache as stale, and recompute', async () => {
cacheStorageService.mdel.mockResolvedValue(undefined);
cacheStorageService.mset.mockResolvedValue(undefined);
@@ -313,10 +318,44 @@ describe('WorkspaceCacheService', () => {
]),
);
});
it('should keep old versions in local cache after invalidation for race condition safety', async () => {
cacheStorageService.mget.mockResolvedValue([undefined]);
cacheStorageService.mset.mockResolvedValue(undefined);
cacheStorageService.mdel.mockResolvedValue(undefined);
const initialData = { testData: 'initial-value' };
const recomputedData = { testData: 'recomputed-value' };
jest
.spyOn(mockProvider, 'computeForCache')
.mockResolvedValue(initialData);
// First, populate the cache
const firstResult = await service.getOrRecompute(WORKSPACE_ID, [
'featureFlagsMap',
]);
expect(firstResult).toEqual({ featureFlagsMap: initialData });
// Now invalidate and recompute
jest
.spyOn(mockProvider, 'computeForCache')
.mockResolvedValue(recomputedData);
await service.invalidateAndRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// The new value should be returned
const secondResult = await service.getOrRecompute(WORKSPACE_ID, [
'featureFlagsMap',
]);
expect(secondResult).toEqual({ featureFlagsMap: recomputedData });
});
});
describe('flush', () => {
it('should delete from redis and local cache', async () => {
it('should delete from redis and mark local cache as stale', async () => {
cacheStorageService.mdel.mockResolvedValue(undefined);
await service.flush(WORKSPACE_ID, ['featureFlagsMap']);
@@ -334,5 +373,134 @@ describe('WorkspaceCacheService', () => {
expect(cacheStorageService.mdel).toHaveBeenCalledWith([]);
});
it('should force staleness on local cache entries without deleting them', async () => {
cacheStorageService.mget.mockResolvedValue([undefined]);
cacheStorageService.mset.mockResolvedValue(undefined);
cacheStorageService.mdel.mockResolvedValue(undefined);
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
testData: 'computed-value',
});
discoveryService.getProviders.mockReturnValue([
{ instance: mockProvider },
] as any);
reflector.get.mockImplementation((key, target) => {
if (
key === WORKSPACE_CACHE_KEY &&
target === MockFeatureFlagsCacheProvider
) {
return 'featureFlagsMap';
}
return undefined;
});
await service.onModuleInit();
// Populate the cache
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Flush the cache (marks as stale, doesn't delete)
await service.flush(WORKSPACE_ID, ['featureFlagsMap']);
// Advance time slightly (but still within memoizer TTL)
jest.advanceTimersByTime(50);
// Next call should check Redis since local cache is marked stale
cacheStorageService.mget.mockResolvedValue(['some-hash']);
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Should have made additional mget calls to check Redis
expect(cacheStorageService.mget.mock.calls.length).toBeGreaterThan(1);
});
});
describe('versioning behavior', () => {
beforeEach(async () => {
discoveryService.getProviders.mockReturnValue([
{ instance: mockProvider },
] as any);
reflector.get.mockImplementation((key, target) => {
if (
key === WORKSPACE_CACHE_KEY &&
target === MockFeatureFlagsCacheProvider
) {
return 'featureFlagsMap';
}
return undefined;
});
await service.onModuleInit();
});
it('should store multiple versions when data is recomputed', async () => {
cacheStorageService.mget.mockResolvedValue([undefined]);
cacheStorageService.mset.mockResolvedValue(undefined);
cacheStorageService.mdel.mockResolvedValue(undefined);
const firstData = { testData: 'first-value' };
const secondData = { testData: 'second-value' };
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue(firstData);
// First computation
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Invalidate and recompute with new data
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue(secondData);
await service.invalidateAndRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Should return the latest version
const result = await service.getOrRecompute(WORKSPACE_ID, [
'featureFlagsMap',
]);
expect(result).toEqual({ featureFlagsMap: secondData });
});
it('should cleanup stale versions after TTL expires', async () => {
cacheStorageService.mget.mockResolvedValue([undefined]);
cacheStorageService.mset.mockResolvedValue(undefined);
cacheStorageService.mdel.mockResolvedValue(undefined);
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
testData: 'value-1',
});
// Create first version
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
// Create multiple versions by invalidating
for (let i = 2; i <= 4; i++) {
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
testData: `value-${i}`,
});
await service.invalidateAndRecompute(WORKSPACE_ID, ['featureFlagsMap']);
}
// Advance time past the stale version TTL (5000ms)
jest.advanceTimersByTime(6_000);
// Advance past memoizer TTL as well
jest.advanceTimersByTime(15_000);
// Trigger a read which should cleanup stale versions
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
testData: 'latest-value',
});
const result = await service.getOrRecompute(WORKSPACE_ID, [
'featureFlagsMap',
]);
expect(result).toEqual({ featureFlagsMap: { testData: 'latest-value' } });
});
});
});
@@ -1,4 +1,4 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import crypto from 'crypto';
@@ -11,7 +11,11 @@ import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decora
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import { WORKSPACE_CACHE_KEY } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import {
WORKSPACE_CACHE_KEY,
WORKSPACE_CACHE_OPTIONS,
WorkspaceCacheOptions,
} from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import {
WorkspaceCacheException,
WorkspaceCacheExceptionCode,
@@ -24,8 +28,10 @@ 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_STALENESS_TTL_MS = 100;
const LOCAL_TTL_MS = 100;
const MEMOIZER_TTL_MS = 10_000;
const STALE_VERSION_TTL_MS = 5_000;
const MAX_LOCAL_STALE_VERSIONS = 5;
type CacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName];
@@ -39,10 +45,13 @@ export class WorkspaceCacheService implements OnModuleInit {
WorkspaceCacheKeyName,
WorkspaceCacheProvider<CacheDataType>
>();
private readonly localDataOnlyKeys = new Set<WorkspaceCacheKeyName>();
private readonly memoizer = new PromiseMemoizer<
Partial<WorkspaceCacheDataMap>
>(MEMOIZER_TTL_MS);
private readonly logger = new Logger(WorkspaceCacheService.name);
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
private readonly cacheStorage: CacheStorageService,
@@ -70,17 +79,27 @@ export class WorkspaceCacheService implements OnModuleInit {
instance instanceof WorkspaceCacheProvider
) {
this.workspaceCacheProviders.set(workspaceCacheKeyName, instance);
const options: WorkspaceCacheOptions | undefined =
this.reflector.get<WorkspaceCacheOptions>(
WORKSPACE_CACHE_OPTIONS,
instance.constructor,
);
if (options?.localDataOnly) {
this.localDataOnlyKeys.add(workspaceCacheKeyName);
}
}
}
}
public async getOrRecompute<const K extends WorkspaceCacheKeyName[]>(
workspaceId: string,
workspaceCacheKeyNames: K,
cacheKeyNames: K,
): Promise<WorkspaceCacheResult<K>> {
if (
!isDefined(workspaceId) ||
workspaceCacheKeyNames.length === 0 ||
cacheKeyNames.length === 0 ||
!isValidUuid(workspaceId)
) {
throw new WorkspaceCacheException(
@@ -90,42 +109,46 @@ export class WorkspaceCacheService implements OnModuleInit {
}
const memoKey =
`${workspaceId}-${[...workspaceCacheKeyNames].sort().join(',')}` as const;
`${workspaceId}-${[...cacheKeyNames].sort().join(',')}` as const;
const result = await this.memoizer.memoizePromiseAndExecute(
memoKey,
async () => {
const freshResult: Partial<WorkspaceCacheDataMap> = {};
const { freshKeys, staleKeys } = this.partitionKeysByTTLStaleness(
// Stage 1: Check local TTL
const { freshKeys, staleKeys } = this.checkLocalTTL(
workspaceId,
workspaceCacheKeyNames,
cacheKeyNames,
);
for (const workspaceCacheKeyName of freshKeys) {
const localKey = this.buildCacheKey(
workspaceId,
workspaceCacheKeyName,
);
const cached = this.localCache.get(localKey);
if (isDefined(cached)) {
Object.assign(freshResult, {
[workspaceCacheKeyName]: cached.data,
});
}
}
const freshData = this.getFromLocalCache(workspaceId, freshKeys);
if (staleKeys.length === 0) {
return freshResult;
return freshData;
}
const staleResults = await this.resolveStaleKeys(
// Stage 2: Validate ttl stale keys against Redis hash
const { validKeys, keysNeedingDataFromRedis, keysNeedingRecompute } =
await this.validateLocalHashAgainstRedisHash(workspaceId, staleKeys);
const validatedData = this.getFromLocalCache(workspaceId, validKeys);
// Stage 3: Fetch data from Redis
const { redisData, missingInRedis } = await this.fetchDataFromRedis(
workspaceId,
staleKeys,
keysNeedingDataFromRedis,
);
return { ...freshResult, ...staleResults };
// Stage 4: Recompute remaining
const keysToRecompute = [...keysNeedingRecompute, ...missingInRedis];
const recomputedData = await this.recomputeDataFromProvider(
workspaceId,
keysToRecompute,
);
return {
...freshData,
...validatedData,
...redisData,
...recomputedData,
};
},
);
@@ -134,39 +157,213 @@ export class WorkspaceCacheService implements OnModuleInit {
public async invalidateAndRecompute(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
await this.memoizer.clearKeys(`${workspaceId}-`);
await this.flush(workspaceId, workspaceCacheKeys);
await this.recomputeCache(workspaceId, workspaceCacheKeys);
await this.flush(workspaceId, cacheKeyNames);
await this.recomputeDataFromProvider(workspaceId, cacheKeyNames);
}
public async flush(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
await this.deleteFromRedis(workspaceId, workspaceCacheKeys);
this.deleteFromLocalCache(workspaceId, workspaceCacheKeys);
await this.deleteFromRedis(workspaceId, cacheKeyNames);
this.deleteFromLocalCache(workspaceId, cacheKeyNames);
}
private checkLocalTTL<K extends WorkspaceCacheKeyName>(
workspaceId: string,
cacheKeyNames: readonly K[],
): { freshKeys: K[]; staleKeys: K[] } {
const freshKeys: K[] = [];
const staleKeys: K[] = [];
const now = Date.now();
for (const keyName of cacheKeyNames) {
const localKey = this.buildCacheKey(workspaceId, keyName);
const cached = this.localCache.get(localKey);
if (isDefined(cached) && now - cached.lastHashCheckedAt < LOCAL_TTL_MS) {
freshKeys.push(keyName);
} else {
staleKeys.push(keyName);
}
}
return { freshKeys, staleKeys };
}
private async validateLocalHashAgainstRedisHash(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<{
validKeys: WorkspaceCacheKeyName[];
keysNeedingDataFromRedis: WorkspaceCacheKeyName[];
keysNeedingRecompute: WorkspaceCacheKeyName[];
}> {
const validKeys: WorkspaceCacheKeyName[] = [];
const keysNeedingDataFromRedis: WorkspaceCacheKeyName[] = [];
const keysNeedingRecompute: WorkspaceCacheKeyName[] = [];
if (cacheKeyNames.length === 0) {
return { validKeys, keysNeedingDataFromRedis, keysNeedingRecompute };
}
const hashKeys = cacheKeyNames.map(
(keyName) => `${this.buildCacheKey(workspaceId, keyName)}:hash`,
);
const redisHashes = await this.cacheStorage.mget<string>(hashKeys);
for (const [index, keyName] of cacheKeyNames.entries()) {
const redisHash = redisHashes[index];
const localKey = this.buildCacheKey(workspaceId, keyName);
const localEntry = this.localCache.get(localKey);
if (
isDefined(localEntry) &&
isDefined(redisHash) &&
localEntry.latestHash === redisHash
) {
localEntry.lastHashCheckedAt = Date.now();
validKeys.push(keyName);
} else if (this.localDataOnlyKeys.has(keyName)) {
keysNeedingRecompute.push(keyName);
} else {
keysNeedingDataFromRedis.push(keyName);
}
}
return { validKeys, keysNeedingDataFromRedis, keysNeedingRecompute };
}
private async fetchDataFromRedis(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<{
redisData: Partial<WorkspaceCacheDataMap>;
missingInRedis: WorkspaceCacheKeyName[];
}> {
const redisData: Partial<WorkspaceCacheDataMap> = {};
const missingInRedis: WorkspaceCacheKeyName[] = [];
if (cacheKeyNames.length === 0) {
return { redisData, missingInRedis };
}
// Interleave data and hash keys for atomic fetch: [data1, hash1, data2, hash2, ...]
const allKeys = cacheKeyNames.flatMap((keyName) => {
const baseKey = this.buildCacheKey(workspaceId, keyName);
return [`${baseKey}:data`, `${baseKey}:hash`];
});
const allValues = await this.cacheStorage.mget<CacheDataType | string>(
allKeys,
);
for (const [index, keyName] of cacheKeyNames.entries()) {
const data = allValues[index * 2] as CacheDataType | undefined;
const hash = allValues[index * 2 + 1] as string | undefined;
if (isDefined(data) && isDefined(hash)) {
Object.assign(redisData, { [keyName]: data });
this.setInLocalCache(workspaceId, keyName, data, hash);
} else {
missingInRedis.push(keyName);
}
}
return { redisData, missingInRedis };
}
private async recomputeDataFromProvider(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<Partial<WorkspaceCacheDataMap>> {
const result: Partial<WorkspaceCacheDataMap> = {};
if (cacheKeyNames.length === 0) {
return result;
}
const computePromises = cacheKeyNames.map(async (keyName) => {
const provider = this.getProviderOrThrow(keyName);
const data = await provider.computeForCache(workspaceId);
const hash = crypto.randomUUID();
return { keyName, data, hash };
});
const computed = await Promise.all(computePromises);
const redisEntries: Array<{ key: string; value: unknown }> = [];
for (const { keyName, data, hash } of computed) {
Object.assign(result, { [keyName]: data });
const baseKey = this.buildCacheKey(workspaceId, keyName);
redisEntries.push({ key: `${baseKey}:hash`, value: hash });
if (!this.localDataOnlyKeys.has(keyName)) {
redisEntries.push({ key: `${baseKey}:data`, value: data });
}
this.setInLocalCache(workspaceId, keyName, data, hash);
}
if (redisEntries.length > 0) {
await this.cacheStorage.mset(redisEntries);
}
return result;
}
private getFromLocalCache(
workspaceId: string,
workspaceCacheKeyNames: WorkspaceCacheKeyName[],
): Partial<WorkspaceCacheDataMap> {
const result: Partial<WorkspaceCacheDataMap> = {};
for (const keyName of workspaceCacheKeyNames) {
const localKey = this.buildCacheKey(workspaceId, keyName);
const entry = this.localCache.get(localKey);
const version = entry?.versions.get(entry.latestHash);
if (isDefined(entry) && isDefined(version)) {
version.lastReadAt = Date.now();
Object.assign(result, { [keyName]: version.data });
this.cleanupStaleVersions(entry);
}
}
return result;
}
private deleteFromLocalCache(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
cacheKeyNames: WorkspaceCacheKeyName[],
): void {
for (const workspaceCacheKeyName of workspaceCacheKeys) {
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
for (const keyName of cacheKeyNames) {
const localKey = this.buildCacheKey(workspaceId, keyName);
const entry = this.localCache.get(localKey);
this.localCache.delete(localKey);
if (isDefined(entry)) {
entry.lastHashCheckedAt = 0;
}
}
}
private async deleteFromRedis(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
const keysToDelete = workspaceCacheKeys.flatMap((workspaceCacheKeyName) => {
const baseKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
const keysToDelete = cacheKeyNames.flatMap((keyName) => {
const baseKey = this.buildCacheKey(workspaceId, keyName);
return [`${baseKey}:data`, `${baseKey}:hash`];
});
@@ -174,267 +371,73 @@ export class WorkspaceCacheService implements OnModuleInit {
await this.cacheStorage.mdel(keysToDelete);
}
private async recomputeCache(
private setInLocalCache(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
): Promise<void> {
const computed = await this.computeAndStoreInRedis(
workspaceId,
workspaceCacheKeys,
);
keyName: WorkspaceCacheKeyName,
data: CacheDataType,
hash: string,
): void {
const localKey = this.buildCacheKey(workspaceId, keyName);
let entry = this.localCache.get(localKey);
for (const { workspaceCacheKeyName, data, hash } of computed) {
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
if (!isDefined(entry)) {
entry = { versions: new Map(), latestHash: '', lastHashCheckedAt: 0 };
this.localCache.set(localKey, entry);
}
entry.versions.set(hash, { data, lastReadAt: Date.now() });
entry.latestHash = hash;
entry.lastHashCheckedAt = Date.now();
}
private partitionKeysByTTLStaleness<K extends WorkspaceCacheKeyName>(
workspaceId: string,
workspaceCacheKeys: readonly K[],
): { freshKeys: K[]; staleKeys: K[] } {
const freshKeys: K[] = [];
const staleKeys: K[] = [];
private cleanupStaleVersions(
entry: WorkspaceLocalCacheEntry<CacheDataType>,
): void {
const now = Date.now();
for (const workspaceCacheKeyName of workspaceCacheKeys) {
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
const cached = this.localCache.get(localKey);
for (const [hash, version] of entry.versions) {
if (
isDefined(cached) &&
now - cached.lastCheckedAt < LOCAL_STALENESS_TTL_MS
hash !== entry.latestHash &&
now - version.lastReadAt > STALE_VERSION_TTL_MS
) {
freshKeys.push(workspaceCacheKeyName);
} else {
staleKeys.push(workspaceCacheKeyName);
entry.versions.delete(hash);
}
}
return { freshKeys, staleKeys };
}
if (entry.versions.size >= MAX_LOCAL_STALE_VERSIONS) {
const sorted = [...entry.versions.entries()]
.filter(([hash]) => hash !== entry.latestHash)
.sort((entryA, entryB) => entryA[1].lastReadAt - entryB[1].lastReadAt);
private async resolveStaleKeys(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
): Promise<Partial<WorkspaceCacheDataMap>> {
const result: Partial<WorkspaceCacheDataMap> = {};
const { validFromLocal, needsRedisCheck } =
await this.partitionKeysByLocalStaleness(workspaceId, workspaceCacheKeys);
for (const workspaceCacheKeyName of validFromLocal) {
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
const localEntry = this.localCache.get(localKey);
if (!isDefined(localEntry)) {
continue;
}
Object.assign(result, { [workspaceCacheKeyName]: localEntry.data });
this.setInLocalCache(
workspaceId,
workspaceCacheKeyName,
localEntry.data,
localEntry.hash,
);
}
if (needsRedisCheck.length === 0) {
return result;
}
const {
validDataFromRedis: validFromRedis,
cacheKeysToRecomputeFromProviders: needsCompute,
} = await this.fetchDataFromRedis(workspaceId, needsRedisCheck);
for (const { workspaceCacheKeyName, data, hash } of validFromRedis) {
Object.assign(result, { [workspaceCacheKeyName]: data });
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
}
if (needsCompute.length === 0) {
return result;
}
const computed = await this.computeAndStoreInRedis(
workspaceId,
needsCompute,
);
for (const { workspaceCacheKeyName, data, hash } of computed) {
Object.assign(result, { [workspaceCacheKeyName]: data });
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
}
return result;
}
private async partitionKeysByLocalStaleness(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
): Promise<{
validFromLocal: WorkspaceCacheKeyName[];
needsRedisCheck: WorkspaceCacheKeyName[];
}> {
const validFromLocal: WorkspaceCacheKeyName[] = [];
const needsRedisCheck: WorkspaceCacheKeyName[] = [];
const hashKeys = workspaceCacheKeys.map(
(workspaceCacheKeyName) =>
`${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:hash`,
);
const redisHashes = await this.cacheStorage.mget<string>(hashKeys);
for (const [index, workspaceCacheKeyName] of workspaceCacheKeys.entries()) {
const redisHash = redisHashes[index];
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
const localEntry = this.localCache.get(localKey);
if (
isDefined(localEntry) &&
isDefined(redisHash) &&
localEntry.hash === redisHash
while (
entry.versions.size >= MAX_LOCAL_STALE_VERSIONS &&
sorted.length > 0
) {
validFromLocal.push(workspaceCacheKeyName);
} else {
needsRedisCheck.push(workspaceCacheKeyName);
const oldestEntry = sorted.shift();
if (isDefined(oldestEntry)) {
entry.versions.delete(oldestEntry[0]);
}
}
}
return { validFromLocal, needsRedisCheck };
}
private async fetchDataFromRedis(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
): Promise<{
validDataFromRedis: Array<{
workspaceCacheKeyName: WorkspaceCacheKeyName;
data: CacheDataType;
hash: string;
}>;
cacheKeysToRecomputeFromProviders: WorkspaceCacheKeyName[];
}> {
const validDataFromRedis: Array<{
workspaceCacheKeyName: WorkspaceCacheKeyName;
data: CacheDataType;
hash: string;
}> = [];
const cacheKeysToRecomputeFromProviders: WorkspaceCacheKeyName[] = [];
const dataKeys = workspaceCacheKeys.map(
(workspaceCacheKeyName) =>
`${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:data`,
);
const redisData = await this.cacheStorage.mget<CacheDataType>(dataKeys);
for (const [index, workspaceCacheKeyName] of workspaceCacheKeys.entries()) {
const data = redisData[index];
if (isDefined(data)) {
const hash = this.generateHash(data);
validDataFromRedis.push({ workspaceCacheKeyName, data, hash });
} else {
cacheKeysToRecomputeFromProviders.push(workspaceCacheKeyName);
}
}
return {
validDataFromRedis,
cacheKeysToRecomputeFromProviders,
};
}
private getProviderOrThrow(
workspaceCacheKeyName: WorkspaceCacheKeyName,
keyName: WorkspaceCacheKeyName,
): WorkspaceCacheProvider<CacheDataType> {
const provider = this.workspaceCacheProviders.get(workspaceCacheKeyName);
const provider = this.workspaceCacheProviders.get(keyName);
if (!isDefined(provider)) {
throw new Error(
`Cache provider with key name "${workspaceCacheKeyName}" not found`,
);
throw new Error(`Cache provider with key name "${keyName}" not found`);
}
return provider;
}
private async computeAndStoreInRedis(
workspaceId: string,
workspaceCacheKeys: WorkspaceCacheKeyName[],
): Promise<
Array<{
workspaceCacheKeyName: WorkspaceCacheKeyName;
data: CacheDataType;
hash: string;
}>
> {
const computePromises = workspaceCacheKeys.map(
async (workspaceCacheKeyName) => {
const provider = this.getProviderOrThrow(workspaceCacheKeyName);
const data = await provider.computeForCache(workspaceId);
return { workspaceCacheKeyName, data };
},
);
const computed = await Promise.all(computePromises);
const redisEntries: Array<{ key: string; value: unknown }> = [];
for (const { workspaceCacheKeyName, data } of computed) {
const hash = this.generateHash(data);
redisEntries.push({
key: `${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:data`,
value: data,
});
redisEntries.push({
key: `${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:hash`,
value: hash,
});
}
await this.cacheStorage.mset(redisEntries);
return computed.map(({ workspaceCacheKeyName, data }) => ({
workspaceCacheKeyName,
data,
hash: this.generateHash(data),
}));
}
private setInLocalCache(
workspaceId: string,
workspaceCacheKeyName: WorkspaceCacheKeyName,
data: CacheDataType,
hash: string,
): void {
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
this.localCache.set(localKey, {
data,
hash,
lastCheckedAt: Date.now(),
});
}
private buildCacheKey(
workspaceId: string,
workspaceCacheKeyName: WorkspaceCacheKeyName,
keyName: WorkspaceCacheKeyName,
): string {
return `${WORKSPACE_CACHE_KEYS_V2[workspaceCacheKeyName]}:${workspaceId}`;
}
private generateHash(data: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(data))
.digest('hex');
return `${WORKSPACE_CACHE_KEYS_V2[keyName]}:${workspaceId}`;
}
}
@@ -1,4 +1,5 @@
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { type EntityMetadata } from 'typeorm';
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@@ -25,6 +26,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
flatApplicationMaps: 'flat-maps:flatApplicationMaps',
flatRoleMaps: 'flat-maps:role',
flatRoleTargetMaps: 'flat-maps:role-target',
ORMEntityMetadatas: 'orm:entity-metadatas',
flatAgentMaps: 'flat-maps:agent',
flatRoleTargetByAgentIdMaps: 'flat-maps:flatRoleTargetByAgentId',
} as const satisfies Record<WorkspaceCacheKeyName, string>;
@@ -35,6 +37,7 @@ type AdditionalCacheDataMap = {
userWorkspaceRoleMap: UserWorkspaceRoleMap;
apiKeyRoleMap: Record<string, string>;
flatApplicationMaps: FlatApplicationCacheMaps;
ORMEntityMetadatas: EntityMetadata[];
flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps;
};
@@ -1,5 +1,10 @@
export type WorkspaceLocalCacheEntry<T> = {
export type VersionEntry<T> = {
data: T;
hash: string;
lastCheckedAt: number;
lastReadAt: number;
};
export type WorkspaceLocalCacheEntry<T> = {
versions: Map<string, VersionEntry<T>>;
latestHash: string;
lastHashCheckedAt: number;
};