Refactor workspace cache service (#16208)
## Context We've recently introduced a new workspace cache service which now acts as a cache access and local storage for all workspace related data, deprecating the individual specific services. - Better performance through multiple caching/fetching strategies - Consistent data access patterns across the codebase - Reduced redis queries through MGET/MSET/PIPELINE with multiple cache keys
This commit is contained in:
+4
-2
@@ -1,6 +1,8 @@
|
||||
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 const WorkspaceCache = (workspaceCacheKey: string) =>
|
||||
SetMetadata(WORKSPACE_CACHE_KEY, workspaceCacheKey);
|
||||
export const WorkspaceCache = (workspaceCacheKeyName: WorkspaceCacheKeyName) =>
|
||||
SetMetadata(WORKSPACE_CACHE_KEY, workspaceCacheKeyName);
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type WorkspaceCacheDataMap,
|
||||
type WorkspaceCacheKeyName,
|
||||
} from 'src/engine/workspace-cache/types/workspace-cache-key.type';
|
||||
|
||||
type WorkspaceCacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName];
|
||||
|
||||
@Injectable()
|
||||
export abstract class WorkspaceCacheProvider<
|
||||
T extends WorkspaceCacheDataType = WorkspaceCacheDataType,
|
||||
> {
|
||||
abstract computeForCache(workspaceId: string): Promise<T>;
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import { DiscoveryService, Reflector } from '@nestjs/core';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import { type 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 { WORKSPACE_CACHE_KEY } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const WORKSPACE_ID = 'test-workspace-id';
|
||||
|
||||
class MockFeatureFlagsCacheProvider extends WorkspaceCacheProvider<{
|
||||
testData: string;
|
||||
}> {
|
||||
async computeForCache(_workspaceId: string) {
|
||||
return { testData: 'computed-value' };
|
||||
}
|
||||
}
|
||||
|
||||
class MockRolesPermissionsCacheProvider extends WorkspaceCacheProvider<{
|
||||
testData: string;
|
||||
}> {
|
||||
async computeForCache(_workspaceId: string) {
|
||||
return { testData: 'computed-value' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkspaceCacheService', () => {
|
||||
let service: WorkspaceCacheService;
|
||||
let cacheStorageService: jest.Mocked<CacheStorageService>;
|
||||
let discoveryService: jest.Mocked<DiscoveryService>;
|
||||
let reflector: jest.Mocked<Reflector>;
|
||||
let mockProvider: MockFeatureFlagsCacheProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
mockProvider = new MockFeatureFlagsCacheProvider();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceCacheService,
|
||||
{
|
||||
provide: CacheStorageNamespace.EngineWorkspace,
|
||||
useValue: {
|
||||
mget: jest.fn(),
|
||||
mset: jest.fn(),
|
||||
mdel: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DiscoveryService,
|
||||
useValue: {
|
||||
getProviders: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Reflector,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceCacheService>(WorkspaceCacheService);
|
||||
cacheStorageService = module.get(CacheStorageNamespace.EngineWorkspace);
|
||||
discoveryService = module.get(DiscoveryService);
|
||||
reflector = module.get(Reflector);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('should register workspace cache providers', 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();
|
||||
|
||||
expect(discoveryService.getProviders).toHaveBeenCalled();
|
||||
expect(reflector.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip non-object instances', async () => {
|
||||
discoveryService.getProviders.mockReturnValue([
|
||||
{ instance: null },
|
||||
{ instance: undefined },
|
||||
{ instance: 'string-value' },
|
||||
] as any);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(reflector.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip instances without workspace cache key metadata', async () => {
|
||||
discoveryService.getProviders.mockReturnValue([
|
||||
{ instance: mockProvider },
|
||||
] as any);
|
||||
|
||||
reflector.get.mockReturnValue(undefined);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(reflector.get).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrRecompute', () => {
|
||||
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 compute and cache data when redis cache is empty', async () => {
|
||||
cacheStorageService.mget.mockResolvedValue([undefined]);
|
||||
cacheStorageService.mset.mockResolvedValue(undefined);
|
||||
|
||||
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
|
||||
testData: 'fresh-computed-value',
|
||||
});
|
||||
|
||||
const result = await service.getOrRecompute(WORKSPACE_ID, [
|
||||
'featureFlagsMap',
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
featureFlagsMap: { testData: 'fresh-computed-value' },
|
||||
});
|
||||
expect(mockProvider.computeForCache).toHaveBeenCalledWith(WORKSPACE_ID);
|
||||
expect(cacheStorageService.mset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return data from redis when available', async () => {
|
||||
const cachedData = { FLAG_A: true, FLAG_B: false };
|
||||
|
||||
cacheStorageService.mget
|
||||
.mockResolvedValueOnce([undefined])
|
||||
.mockResolvedValueOnce([cachedData]);
|
||||
|
||||
const result = await service.getOrRecompute(WORKSPACE_ID, [
|
||||
'featureFlagsMap',
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ featureFlagsMap: cachedData });
|
||||
});
|
||||
|
||||
it('should use local cache when within TTL staleness window', async () => {
|
||||
cacheStorageService.mget.mockResolvedValue([undefined]);
|
||||
cacheStorageService.mset.mockResolvedValue(undefined);
|
||||
|
||||
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
|
||||
testData: 'computed-value',
|
||||
});
|
||||
|
||||
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
|
||||
|
||||
jest.advanceTimersByTime(50);
|
||||
|
||||
const result = await service.getOrRecompute(WORKSPACE_ID, [
|
||||
'featureFlagsMap',
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
featureFlagsMap: { testData: 'computed-value' },
|
||||
});
|
||||
|
||||
expect(mockProvider.computeForCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should recheck redis when local cache exceeds TTL staleness window', async () => {
|
||||
const initialData = { testData: 'initial-value' };
|
||||
const updatedData = { testData: 'updated-value' };
|
||||
|
||||
cacheStorageService.mget.mockResolvedValue([undefined]);
|
||||
cacheStorageService.mset.mockResolvedValue(undefined);
|
||||
|
||||
jest
|
||||
.spyOn(mockProvider, 'computeForCache')
|
||||
.mockResolvedValue(initialData);
|
||||
|
||||
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
|
||||
|
||||
// Advance past the local staleness TTL (100ms) and memoizer TTL (10s)
|
||||
jest.advanceTimersByTime(15_000);
|
||||
|
||||
cacheStorageService.mget.mockResolvedValue([updatedData]);
|
||||
|
||||
jest
|
||||
.spyOn(mockProvider, 'computeForCache')
|
||||
.mockResolvedValue(updatedData);
|
||||
|
||||
await service.getOrRecompute(WORKSPACE_ID, ['featureFlagsMap']);
|
||||
|
||||
// Each getOrRecompute call triggers 2 mget calls (hash check + data fetch)
|
||||
expect(cacheStorageService.mget).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateAndRecompute', () => {
|
||||
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 delete from redis and local cache, then recompute', async () => {
|
||||
cacheStorageService.mdel.mockResolvedValue(undefined);
|
||||
cacheStorageService.mset.mockResolvedValue(undefined);
|
||||
|
||||
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
|
||||
testData: 'recomputed-value',
|
||||
});
|
||||
|
||||
await service.invalidateAndRecompute(WORKSPACE_ID, ['featureFlagsMap']);
|
||||
|
||||
expect(cacheStorageService.mdel).toHaveBeenCalledWith([
|
||||
'feature-flag:feature-flags-map:test-workspace-id:data',
|
||||
'feature-flag:feature-flags-map:test-workspace-id:hash',
|
||||
]);
|
||||
expect(mockProvider.computeForCache).toHaveBeenCalledWith(WORKSPACE_ID);
|
||||
expect(cacheStorageService.mset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should invalidate multiple cache keys at once', async () => {
|
||||
const secondMockProvider = new MockRolesPermissionsCacheProvider();
|
||||
|
||||
discoveryService.getProviders.mockReturnValue([
|
||||
{ instance: mockProvider },
|
||||
{ instance: secondMockProvider },
|
||||
] as any);
|
||||
|
||||
reflector.get.mockImplementation((key, target) => {
|
||||
if (key === WORKSPACE_CACHE_KEY) {
|
||||
if (target === MockFeatureFlagsCacheProvider) {
|
||||
return 'featureFlagsMap';
|
||||
}
|
||||
if (target === MockRolesPermissionsCacheProvider) {
|
||||
return 'rolesPermissions';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
cacheStorageService.mdel.mockResolvedValue(undefined);
|
||||
cacheStorageService.mset.mockResolvedValue(undefined);
|
||||
|
||||
jest.spyOn(mockProvider, 'computeForCache').mockResolvedValue({
|
||||
testData: 'recomputed-value',
|
||||
});
|
||||
jest.spyOn(secondMockProvider, 'computeForCache').mockResolvedValue({
|
||||
testData: 'recomputed-value',
|
||||
});
|
||||
|
||||
await service.invalidateAndRecompute(WORKSPACE_ID, [
|
||||
'featureFlagsMap',
|
||||
'rolesPermissions',
|
||||
]);
|
||||
|
||||
expect(cacheStorageService.mdel).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
'feature-flag:feature-flags-map:test-workspace-id:data',
|
||||
'feature-flag:feature-flags-map:test-workspace-id:hash',
|
||||
'metadata:permissions:roles-permissions:test-workspace-id:data',
|
||||
'metadata:permissions:roles-permissions:test-workspace-id:hash',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('should delete from redis and local cache', async () => {
|
||||
cacheStorageService.mdel.mockResolvedValue(undefined);
|
||||
|
||||
await service.flush(WORKSPACE_ID, ['featureFlagsMap']);
|
||||
|
||||
expect(cacheStorageService.mdel).toHaveBeenCalledWith([
|
||||
'feature-flag:feature-flags-map:test-workspace-id:data',
|
||||
'feature-flag:feature-flags-map:test-workspace-id:hash',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty cache keys array', async () => {
|
||||
cacheStorageService.mdel.mockResolvedValue(undefined);
|
||||
|
||||
await service.flush(WORKSPACE_ID, []);
|
||||
|
||||
expect(cacheStorageService.mdel).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+176
-116
@@ -5,30 +5,39 @@ import crypto from 'crypto';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
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 { WorkspaceContextLocalCacheEntry } from 'src/engine/workspace-cache/types/workspace-context-cache-entry.type';
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/workspace-cache-provider.service';
|
||||
import {
|
||||
WORKSPACE_CACHE_KEYS_V2,
|
||||
type WorkspaceCacheDataMap,
|
||||
type WorkspaceCacheKeyName,
|
||||
type WorkspaceCacheResult,
|
||||
} 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 = 30_000;
|
||||
const LOCAL_STALENESS_TTL_MS = 100;
|
||||
const MEMOIZER_TTL_MS = 10_000;
|
||||
|
||||
type CacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName];
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceCacheService implements OnModuleInit {
|
||||
private readonly localCache = new Map<
|
||||
string,
|
||||
WorkspaceContextLocalCacheEntry<unknown>
|
||||
WorkspaceLocalCacheEntry<CacheDataType>
|
||||
>();
|
||||
private readonly workspaceCacheProviders = new Map<
|
||||
string,
|
||||
WorkspaceCacheProvider<unknown>
|
||||
WorkspaceCacheKeyName,
|
||||
WorkspaceCacheProvider<CacheDataType>
|
||||
>();
|
||||
private readonly memoizer = new PromiseMemoizer<Record<string, unknown>>(
|
||||
MEMOIZER_TTL_MS,
|
||||
);
|
||||
private readonly memoizer = new PromiseMemoizer<
|
||||
Partial<WorkspaceCacheDataMap>
|
||||
>(MEMOIZER_TTL_MS);
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
|
||||
@@ -47,41 +56,49 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceCacheKey = this.reflector.get<string>(
|
||||
const workspaceCacheKeyName = this.reflector.get<WorkspaceCacheKeyName>(
|
||||
WORKSPACE_CACHE_KEY,
|
||||
instance.constructor,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(workspaceCacheKey) &&
|
||||
isDefined(workspaceCacheKeyName) &&
|
||||
instance instanceof WorkspaceCacheProvider
|
||||
) {
|
||||
this.workspaceCacheProviders.set(workspaceCacheKey, instance);
|
||||
this.workspaceCacheProviders.set(workspaceCacheKeyName, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getOrRecompute<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(workspaceId: string, workspaceCacheKeys: string[]): Promise<T> {
|
||||
async getOrRecompute<const K extends WorkspaceCacheKeyName[]>(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeyNames: K,
|
||||
): Promise<WorkspaceCacheResult<K>> {
|
||||
const memoKey =
|
||||
`${workspaceId}-${[...workspaceCacheKeys].sort().join(',')}` as const;
|
||||
`${workspaceId}-${[...workspaceCacheKeyNames].sort().join(',')}` as const;
|
||||
|
||||
const result = await this.memoizer.memoizePromiseAndExecute(
|
||||
memoKey,
|
||||
async () => {
|
||||
const freshResult: Record<string, unknown> = {};
|
||||
const freshResult: Partial<WorkspaceCacheDataMap> = {};
|
||||
|
||||
const { freshKeys, staleKeys } = this.partitionKeysByTTLStaleness(
|
||||
workspaceId,
|
||||
workspaceCacheKeys,
|
||||
workspaceCacheKeyNames,
|
||||
);
|
||||
|
||||
for (const workspaceCacheKey of freshKeys) {
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
for (const workspaceCacheKeyName of freshKeys) {
|
||||
const localKey = this.buildCacheKey(
|
||||
workspaceId,
|
||||
workspaceCacheKeyName,
|
||||
);
|
||||
const cached = this.localCache.get(localKey);
|
||||
|
||||
freshResult[workspaceCacheKey] = cached?.data;
|
||||
if (isDefined(cached)) {
|
||||
Object.assign(freshResult, {
|
||||
[workspaceCacheKeyName]: cached.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (staleKeys.length === 0) {
|
||||
@@ -97,48 +114,84 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
},
|
||||
);
|
||||
|
||||
return result as T;
|
||||
return result as WorkspaceCacheResult<K>;
|
||||
}
|
||||
|
||||
invalidate(workspaceId: string, workspaceCacheKeys?: string[]): void {
|
||||
if (!isDefined(workspaceCacheKeys) || workspaceCacheKeys.length === 0) {
|
||||
const allKeys = Array.from(this.localCache.keys());
|
||||
async invalidateAndRecompute(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<void> {
|
||||
await this.memoizer.clearKeys(`${workspaceId}-`);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key.startsWith(this.getCacheKey(workspaceId, ''))) {
|
||||
this.localCache.delete(key);
|
||||
}
|
||||
}
|
||||
await this.flush(workspaceId, workspaceCacheKeys);
|
||||
await this.recomputeCache(workspaceId, workspaceCacheKeys);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
async flush(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<void> {
|
||||
await this.deleteFromRedis(workspaceId, workspaceCacheKeys);
|
||||
this.deleteFromLocalCache(workspaceId, workspaceCacheKeys);
|
||||
}
|
||||
|
||||
for (const workspaceCacheKey of workspaceCacheKeys) {
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
private deleteFromLocalCache(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): void {
|
||||
for (const workspaceCacheKeyName of workspaceCacheKeys) {
|
||||
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
|
||||
this.localCache.delete(localKey);
|
||||
}
|
||||
}
|
||||
|
||||
private partitionKeysByTTLStaleness(
|
||||
private async deleteFromRedis(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: string[],
|
||||
): { freshKeys: string[]; staleKeys: string[] } {
|
||||
const freshKeys: string[] = [];
|
||||
const staleKeys: string[] = [];
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<void> {
|
||||
const keysToDelete = workspaceCacheKeys.flatMap((workspaceCacheKeyName) => {
|
||||
const baseKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
|
||||
return [`${baseKey}:data`, `${baseKey}:hash`];
|
||||
});
|
||||
|
||||
await this.cacheStorage.mdel(keysToDelete);
|
||||
}
|
||||
|
||||
private async recomputeCache(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<void> {
|
||||
const computed = await this.computeAndStoreInRedis(
|
||||
workspaceId,
|
||||
workspaceCacheKeys,
|
||||
);
|
||||
|
||||
for (const { workspaceCacheKeyName, data, hash } of computed) {
|
||||
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
|
||||
}
|
||||
}
|
||||
|
||||
private partitionKeysByTTLStaleness<K extends WorkspaceCacheKeyName>(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: readonly K[],
|
||||
): { freshKeys: K[]; staleKeys: K[] } {
|
||||
const freshKeys: K[] = [];
|
||||
const staleKeys: K[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const workspaceCacheKey of workspaceCacheKeys) {
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
for (const workspaceCacheKeyName of workspaceCacheKeys) {
|
||||
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
const cached = this.localCache.get(localKey);
|
||||
|
||||
if (
|
||||
isDefined(cached) &&
|
||||
now - cached.lastCheckedAt < LOCAL_STALENESS_TTL_MS
|
||||
) {
|
||||
freshKeys.push(workspaceCacheKey);
|
||||
freshKeys.push(workspaceCacheKeyName);
|
||||
} else {
|
||||
staleKeys.push(workspaceCacheKey);
|
||||
staleKeys.push(workspaceCacheKeyName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,25 +200,25 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
|
||||
private async resolveStaleKeys(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: string[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = {};
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<Partial<WorkspaceCacheDataMap>> {
|
||||
const result: Partial<WorkspaceCacheDataMap> = {};
|
||||
|
||||
const { validFromLocal, needsRedisCheck } =
|
||||
await this.partitionKeysByLocalStaleness(workspaceId, workspaceCacheKeys);
|
||||
|
||||
for (const workspaceCacheKey of validFromLocal) {
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
for (const workspaceCacheKeyName of validFromLocal) {
|
||||
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
const localEntry = this.localCache.get(localKey);
|
||||
|
||||
if (!isDefined(localEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[workspaceCacheKey] = localEntry.data;
|
||||
this.updateLocalCache(
|
||||
Object.assign(result, { [workspaceCacheKeyName]: localEntry.data });
|
||||
this.setInLocalCache(
|
||||
workspaceId,
|
||||
workspaceCacheKey,
|
||||
workspaceCacheKeyName,
|
||||
localEntry.data,
|
||||
localEntry.hash,
|
||||
);
|
||||
@@ -180,9 +233,9 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
cacheKeysToRecomputeFromProviders: needsCompute,
|
||||
} = await this.fetchDataFromRedis(workspaceId, needsRedisCheck);
|
||||
|
||||
for (const { workspaceCacheKey, data, hash } of validFromRedis) {
|
||||
result[workspaceCacheKey] = data;
|
||||
this.updateLocalCache(workspaceId, workspaceCacheKey, data, hash);
|
||||
for (const { workspaceCacheKeyName, data, hash } of validFromRedis) {
|
||||
Object.assign(result, { [workspaceCacheKeyName]: data });
|
||||
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
|
||||
}
|
||||
|
||||
if (needsCompute.length === 0) {
|
||||
@@ -194,9 +247,9 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
needsCompute,
|
||||
);
|
||||
|
||||
for (const { workspaceCacheKey, data, hash } of computed) {
|
||||
result[workspaceCacheKey] = data;
|
||||
this.updateLocalCache(workspaceId, workspaceCacheKey, data, hash);
|
||||
for (const { workspaceCacheKeyName, data, hash } of computed) {
|
||||
Object.assign(result, { [workspaceCacheKeyName]: data });
|
||||
this.setInLocalCache(workspaceId, workspaceCacheKeyName, data, hash);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -204,24 +257,24 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
|
||||
private async partitionKeysByLocalStaleness(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: string[],
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<{
|
||||
validFromLocal: string[];
|
||||
needsRedisCheck: string[];
|
||||
validFromLocal: WorkspaceCacheKeyName[];
|
||||
needsRedisCheck: WorkspaceCacheKeyName[];
|
||||
}> {
|
||||
const validFromLocal: string[] = [];
|
||||
const needsRedisCheck: string[] = [];
|
||||
const validFromLocal: WorkspaceCacheKeyName[] = [];
|
||||
const needsRedisCheck: WorkspaceCacheKeyName[] = [];
|
||||
|
||||
const hashKeys = workspaceCacheKeys.map((workspaceCacheKey) => {
|
||||
return `${workspaceId}:${workspaceCacheKey}:hash`;
|
||||
});
|
||||
const hashKeys = workspaceCacheKeys.map(
|
||||
(workspaceCacheKeyName) =>
|
||||
`${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:hash`,
|
||||
);
|
||||
|
||||
const redisHashes = await this.cacheStorage.mget<string>(hashKeys);
|
||||
|
||||
for (let i = 0; i < workspaceCacheKeys.length; i++) {
|
||||
const workspaceCacheKey = workspaceCacheKeys[i];
|
||||
const redisHash = redisHashes[i];
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
for (const [index, workspaceCacheKeyName] of workspaceCacheKeys.entries()) {
|
||||
const redisHash = redisHashes[index];
|
||||
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
const localEntry = this.localCache.get(localKey);
|
||||
|
||||
if (
|
||||
@@ -229,9 +282,9 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
isDefined(redisHash) &&
|
||||
localEntry.hash === redisHash
|
||||
) {
|
||||
validFromLocal.push(workspaceCacheKey);
|
||||
validFromLocal.push(workspaceCacheKeyName);
|
||||
} else {
|
||||
needsRedisCheck.push(workspaceCacheKey);
|
||||
needsRedisCheck.push(workspaceCacheKeyName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,38 +293,38 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
|
||||
private async fetchDataFromRedis(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: string[],
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<{
|
||||
validDataFromRedis: Array<{
|
||||
workspaceCacheKey: string;
|
||||
data: unknown;
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName;
|
||||
data: CacheDataType;
|
||||
hash: string;
|
||||
}>;
|
||||
cacheKeysToRecomputeFromProviders: string[];
|
||||
cacheKeysToRecomputeFromProviders: WorkspaceCacheKeyName[];
|
||||
}> {
|
||||
const validDataFromRedis: Array<{
|
||||
workspaceCacheKey: string;
|
||||
data: unknown;
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName;
|
||||
data: CacheDataType;
|
||||
hash: string;
|
||||
}> = [];
|
||||
const cacheKeysToRecomputeFromProviders: string[] = [];
|
||||
const cacheKeysToRecomputeFromProviders: WorkspaceCacheKeyName[] = [];
|
||||
|
||||
const dataKeys = workspaceCacheKeys.map((workspaceCacheKey) => {
|
||||
return `${this.getCacheKey(workspaceId, workspaceCacheKey)}:data`;
|
||||
});
|
||||
const dataKeys = workspaceCacheKeys.map(
|
||||
(workspaceCacheKeyName) =>
|
||||
`${this.buildCacheKey(workspaceId, workspaceCacheKeyName)}:data`,
|
||||
);
|
||||
|
||||
const redisData = await this.cacheStorage.mget<unknown>(dataKeys);
|
||||
const redisData = await this.cacheStorage.mget<CacheDataType>(dataKeys);
|
||||
|
||||
for (let i = 0; i < workspaceCacheKeys.length; i++) {
|
||||
const workspaceCacheKey = workspaceCacheKeys[i];
|
||||
const data = redisData[i];
|
||||
for (const [index, workspaceCacheKeyName] of workspaceCacheKeys.entries()) {
|
||||
const data = redisData[index];
|
||||
|
||||
if (isDefined(data)) {
|
||||
const hash = this.generateHash(data);
|
||||
|
||||
validDataFromRedis.push({ workspaceCacheKey, data, hash });
|
||||
validDataFromRedis.push({ workspaceCacheKeyName, data, hash });
|
||||
} else {
|
||||
cacheKeysToRecomputeFromProviders.push(workspaceCacheKey);
|
||||
cacheKeysToRecomputeFromProviders.push(workspaceCacheKeyName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,13 +335,13 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private getProviderOrThrow(
|
||||
workspaceCacheKey: string,
|
||||
): WorkspaceCacheProvider<unknown> {
|
||||
const provider = this.workspaceCacheProviders.get(workspaceCacheKey);
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName,
|
||||
): WorkspaceCacheProvider<CacheDataType> {
|
||||
const provider = this.workspaceCacheProviders.get(workspaceCacheKeyName);
|
||||
|
||||
if (!isDefined(provider)) {
|
||||
throw new Error(
|
||||
`Cache provider with key "${workspaceCacheKey}" not found`,
|
||||
`Cache provider with key name "${workspaceCacheKeyName}" not found`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,53 +350,57 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
|
||||
private async computeAndStoreInRedis(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeys: string[],
|
||||
workspaceCacheKeys: WorkspaceCacheKeyName[],
|
||||
): Promise<
|
||||
Array<{ workspaceCacheKey: string; data: unknown; hash: string }>
|
||||
Array<{
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName;
|
||||
data: CacheDataType;
|
||||
hash: string;
|
||||
}>
|
||||
> {
|
||||
const computePromises = workspaceCacheKeys.map(
|
||||
async (workspaceCacheKey) => {
|
||||
const provider = this.getProviderOrThrow(workspaceCacheKey);
|
||||
async (workspaceCacheKeyName) => {
|
||||
const provider = this.getProviderOrThrow(workspaceCacheKeyName);
|
||||
|
||||
const data = await provider.computeForCache(workspaceId);
|
||||
|
||||
return { workspaceCacheKey, data };
|
||||
return { workspaceCacheKeyName, data };
|
||||
},
|
||||
);
|
||||
|
||||
const computed = await Promise.all(computePromises);
|
||||
|
||||
const redisEntries = computed.flatMap(({ workspaceCacheKey, data }) => {
|
||||
const redisEntries: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
for (const { workspaceCacheKeyName, data } of computed) {
|
||||
const hash = this.generateHash(data);
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${this.getCacheKey(workspaceId, workspaceCacheKey)}:data`,
|
||||
value: data,
|
||||
},
|
||||
{
|
||||
key: `${this.getCacheKey(workspaceId, workspaceCacheKey)}:hash`,
|
||||
value: hash,
|
||||
},
|
||||
];
|
||||
});
|
||||
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(({ workspaceCacheKey, data }) => ({
|
||||
workspaceCacheKey,
|
||||
return computed.map(({ workspaceCacheKeyName, data }) => ({
|
||||
workspaceCacheKeyName,
|
||||
data,
|
||||
hash: this.generateHash(data),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateLocalCache(
|
||||
private setInLocalCache(
|
||||
workspaceId: string,
|
||||
workspaceCacheKey: string,
|
||||
data: unknown,
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName,
|
||||
data: CacheDataType,
|
||||
hash: string,
|
||||
): void {
|
||||
const localKey = this.getCacheKey(workspaceId, workspaceCacheKey);
|
||||
const localKey = this.buildCacheKey(workspaceId, workspaceCacheKeyName);
|
||||
|
||||
this.localCache.set(localKey, {
|
||||
data,
|
||||
@@ -352,8 +409,11 @@ export class WorkspaceCacheService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
private getCacheKey(workspaceId: string, workspaceCacheKey: string): string {
|
||||
return `${workspaceId}:${workspaceCacheKey}`;
|
||||
private buildCacheKey(
|
||||
workspaceId: string,
|
||||
workspaceCacheKeyName: WorkspaceCacheKeyName,
|
||||
): string {
|
||||
return `${WORKSPACE_CACHE_KEYS_V2[workspaceCacheKeyName]}:${workspaceId}`;
|
||||
}
|
||||
|
||||
private generateHash(data: unknown): string {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
|
||||
|
||||
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';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
|
||||
|
||||
export const WORKSPACE_CACHE_KEYS_V2 = {
|
||||
flatObjectMetadataMaps: 'flat-maps:object-metadata',
|
||||
flatFieldMetadataMaps: 'flat-maps:field-metadata',
|
||||
flatIndexMaps: 'flat-maps:index',
|
||||
flatViewMaps: 'flat-maps:view',
|
||||
flatViewFieldMaps: 'flat-maps:view-field',
|
||||
flatViewGroupMaps: 'flat-maps:view-group',
|
||||
flatViewFilterMaps: 'flat-maps:view-filter',
|
||||
flatServerlessFunctionMaps: 'flat-maps:serverless-function',
|
||||
flatCronTriggerMaps: 'flat-maps:cron-trigger',
|
||||
flatDatabaseEventTriggerMaps: 'flat-maps:database-event-trigger',
|
||||
flatRouteTriggerMaps: 'flat-maps:route-trigger',
|
||||
featureFlagsMap: 'feature-flag:feature-flags-map',
|
||||
rolesPermissions: 'metadata:permissions:roles-permissions',
|
||||
userWorkspaceRoleMap: 'metadata:permissions:user-workspace-role-map',
|
||||
apiKeyRoleMap: 'metadata:permissions:api-key-role-map',
|
||||
flatApplicationMaps: 'flat-maps:flatApplicationMaps',
|
||||
flatRoleMaps: 'flat-maps:role',
|
||||
flatRoleTargetMaps: 'flat-maps:role-target',
|
||||
} as const satisfies Record<WorkspaceCacheKeyName, string>;
|
||||
|
||||
type AdditionalCacheDataMap = {
|
||||
featureFlagsMap: Record<FeatureFlagKey, boolean>;
|
||||
rolesPermissions: ObjectsPermissionsByRoleId;
|
||||
userWorkspaceRoleMap: UserWorkspaceRoleMap;
|
||||
apiKeyRoleMap: Record<string, string>;
|
||||
flatApplicationMaps: FlatApplicationCacheMaps;
|
||||
};
|
||||
|
||||
export type WorkspaceCacheDataMap = AllFlatEntityMaps & AdditionalCacheDataMap;
|
||||
|
||||
export type WorkspaceCacheKeyName = keyof WorkspaceCacheDataMap;
|
||||
|
||||
export type WorkspaceCacheResult<K extends WorkspaceCacheKeyName[]> = {
|
||||
[P in K[number]]: WorkspaceCacheDataMap[P];
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type WorkspaceContextLocalCacheEntry<T> = {
|
||||
export type WorkspaceLocalCacheEntry<T> = {
|
||||
data: T;
|
||||
hash: string;
|
||||
lastCheckedAt: number;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export abstract class WorkspaceCacheProvider<T> {
|
||||
abstract computeForCache(workspaceId: string): Promise<T>;
|
||||
}
|
||||
@@ -9,4 +9,4 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
|
||||
providers: [WorkspaceCacheService],
|
||||
exports: [WorkspaceCacheService],
|
||||
})
|
||||
export class WorkspaceContextCacheModule {}
|
||||
export class WorkspaceCacheModule {}
|
||||
|
||||
Reference in New Issue
Block a user