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:
+276
-273
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user