Files
twenty/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts
T
Charles Bochet 1ebcbdda42 feat(server): export event-loop delay and workspace-cache recompute metrics (#23751)
## What

Exports three sets of metrics to Prometheus to diagnose the recurring
"Slow DB Query" Sentry issues on `POST /graphql` (e.g. the
`fieldMetadata` select):

- `twenty_nodejs_eventloop_delay_seconds` (mean/p50/p99/max) +
`twenty_nodejs_eventloop_utilization`
- `twenty_workspace_cache_recompute_duration_seconds{cache_key}` — wall
time per provider `computeForCache`
- `twenty_workspace_cache_redis_write_duration_seconds` — serialize +
Redis write time for recomputed entries

## Why

Investigation of these issues showed:
- The flagged query executes in ~1ms (prod EXPLAIN), so it is not a
query/index problem.
- Event counts do **not** correlate with connection-pool acquire latency
(Pearson ~0 against both p99 and the direct count of >1s acquires), so
it is not pool contention.
- Event counts **do** correlate with pod CPU (Pearson +0.40).

The leading explanation is that the slow `db` span is inflated by
event-loop saturation during the workspace metadata cache recompute:
`Promise.all` parallelizes the I/O, but the synchronous work it cannot
parallelize (TypeORM entity hydration of JSONB-heavy result sets, then
`JSON.stringify` of the flat-map payloads into Redis) blocks the single
event-loop thread, so an awaiting query resolves ~1.3s late.

Node event-loop delay was only being collected by Sentry's
`nodeRuntimeMetricsIntegration`, never exported to Prometheus, so it
could not be graphed or correlated in Grafana. These metrics confirm (or
refute) the mechanism and give a before/after baseline for the fix.
Grafana panels land in a companion twenty-infra PR.

## Notes

- Metric-only change; no behavioral change to the cache.
- Uses the same OTel `MetricsService` / meter as the existing DB pool
metrics.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23751?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-04 16:50:50 +02:00

748 lines
22 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import { type Histogram } from '@opentelemetry/api';
import * as Sentry from '@sentry/node';
import crypto from 'crypto';
import { isDefined, isValidUuid } 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import {
WORKSPACE_CACHE_KEY,
WORKSPACE_CACHE_OPTIONS,
WorkspaceCacheOptions,
} from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import {
WorkspaceCacheException,
WorkspaceCacheExceptionCode,
} from 'src/engine/workspace-cache/exceptions/workspace-cache.exception';
import {
WORKSPACE_CACHE_KEYS_V2,
WorkspaceCacheKeyName,
type WorkspaceCacheDataMap,
type WorkspaceCacheResult,
type WorkspaceCacheResultWithHashes,
} from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { type WorkspaceLocalCacheEntry } from 'src/engine/workspace-cache/types/workspace-local-cache-entry.type';
import { combineCacheHashes } from 'src/engine/workspace-cache/utils/combine-cache-hashes.util';
const LOCAL_TTL_MS = 100; // 100ms
const LOCAL_ENTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes
const LOCAL_CACHE_EXPIRATION_SWEEP_INTERVAL_MS = 60 * 1000;
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
// Sized against 4 GiB pods (--max-old-space-size=3500): 7,500 sat at the heap ceiling
const MAX_LOCAL_CACHE_ENTRIES = 6_000;
const MIN_EVICT_KEYS = 100;
const CACHE_DURATION_BUCKETS_SECONDS = [
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
];
type CacheDataType = WorkspaceCacheDataMap[WorkspaceCacheKeyName];
type CacheEntriesResult = {
data: Partial<WorkspaceCacheDataMap>;
hashes: Partial<Record<WorkspaceCacheKeyName, string>>;
};
type RecomputeHashResolution =
| { strategy: 'mint' }
| {
strategy: 'recover';
adoptableHashes: Partial<Record<WorkspaceCacheKeyName, string>>;
};
@Injectable()
export class WorkspaceCacheService implements OnModuleInit {
private readonly localCache = new Map<
string,
WorkspaceLocalCacheEntry<CacheDataType>
>();
private readonly workspaceCacheProviders = new Map<
WorkspaceCacheKeyName,
WorkspaceCacheProvider<CacheDataType>
>();
private readonly localDataOnlyKeys = new Set<WorkspaceCacheKeyName>();
private readonly memoizer = new PromiseMemoizer<CacheEntriesResult>(
MEMOIZER_TTL_MS,
);
private lastLocalCacheExpirationSweepAt: number | undefined;
private readonly logger = new Logger(WorkspaceCacheService.name);
private readonly recomputeDurationHistogram: Histogram;
private readonly redisWriteDurationHistogram: Histogram;
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
private readonly cacheStorage: CacheStorageService,
private readonly discoveryService: DiscoveryService,
private readonly reflector: Reflector,
private readonly metricsService: MetricsService,
private readonly twentyConfigService: TwentyConfigService,
) {
const meter = this.metricsService.getMeter();
this.recomputeDurationHistogram = meter.createHistogram(
'twenty_workspace_cache_recompute_duration_seconds',
{
description:
'Wall-clock time to compute one workspace metadata cache entry from its provider',
unit: 's',
advice: {
explicitBucketBoundaries: CACHE_DURATION_BUCKETS_SECONDS,
},
},
);
this.redisWriteDurationHistogram = meter.createHistogram(
'twenty_workspace_cache_redis_write_duration_seconds',
{
description:
'Wall-clock time to serialize and write recomputed cache entries to Redis',
unit: 's',
advice: {
explicitBucketBoundaries: CACHE_DURATION_BUCKETS_SECONDS,
},
},
);
}
async onModuleInit() {
const providers = this.discoveryService.getProviders();
for (const wrapper of providers) {
const { instance } = wrapper;
if (!isDefined(instance) || typeof instance !== 'object') {
continue;
}
const workspaceCacheKeyName = this.reflector.get<WorkspaceCacheKeyName>(
WORKSPACE_CACHE_KEY,
instance.constructor,
);
if (
isDefined(workspaceCacheKeyName) &&
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,
cacheKeyNames: K,
): Promise<WorkspaceCacheResult<K>> {
const { data } = await this.getOrRecomputeWithHashes(
workspaceId,
cacheKeyNames,
);
return data;
}
public async getOrRecomputeWithHashes<
const K extends WorkspaceCacheKeyName[],
>(
workspaceId: string,
cacheKeyNames: K,
): Promise<WorkspaceCacheResultWithHashes<K>> {
this.evictExpiredLocalEntriesIfNeeded();
this.assertValidCacheParameters(workspaceId, cacheKeyNames);
const memoKey =
`${workspaceId}-${[...cacheKeyNames].sort().join(',')}` as const;
const result = await this.memoizer.memoizePromiseAndExecute(
memoKey,
async () => {
// Stage 1: Check local TTL
const { freshKeys, staleKeys } = this.checkLocalTTL(
workspaceId,
cacheKeyNames,
);
const freshEntries = this.getFromLocalCache(workspaceId, freshKeys);
if (staleKeys.length === 0) {
return freshEntries;
}
// Stage 2: Validate ttl stale keys against Redis hash
const {
validKeys,
keysNeedingDataFromRedis,
keysNeedingRecompute,
adoptableHashes,
} = await this.validateLocalHashAgainstRedisHash(
workspaceId,
staleKeys,
);
const validatedEntries = this.getFromLocalCache(workspaceId, validKeys);
// Stage 3: Fetch data from Redis
const { redisEntries, missingInRedis } = await this.fetchDataFromRedis(
workspaceId,
keysNeedingDataFromRedis,
);
// Stage 4: Recompute remaining
const keysToRecompute = [...keysNeedingRecompute, ...missingInRedis];
const recomputedEntries = await this.recomputeDataFromProvider(
workspaceId,
keysToRecompute,
{ strategy: 'recover', adoptableHashes },
);
return {
data: {
...freshEntries.data,
...validatedEntries.data,
...redisEntries.data,
...recomputedEntries.data,
},
hashes: {
...freshEntries.hashes,
...validatedEntries.hashes,
...redisEntries.hashes,
...recomputedEntries.hashes,
},
};
},
);
return result as WorkspaceCacheResultWithHashes<K>;
}
public async getOrRecomputeCombinedHash(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<string> {
this.assertValidCacheParameters(workspaceId, cacheKeyNames);
const cachedHashes = await this.getCacheHashes(workspaceId, cacheKeyNames);
const missingKeys = cacheKeyNames.filter(
(cacheKeyName) => !isDefined(cachedHashes[cacheKeyName]),
);
if (missingKeys.length === 0) {
return combineCacheHashes(cachedHashes, cacheKeyNames);
}
const { hashes: recomputedHashes } = await this.getOrRecomputeWithHashes(
workspaceId,
missingKeys,
);
return combineCacheHashes(
{ ...cachedHashes, ...recomputedHashes },
cacheKeyNames,
);
}
public async invalidateAndRecompute(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
return Sentry.startSpan(
{
name: 'invalidate and recompute workspace metadata cache',
op: 'cache.invalidate',
onlyIfParent: true,
attributes: { 'cache.key_count': cacheKeyNames.length },
},
async () => {
await this.memoizer.clearKeys(`${workspaceId}-`);
await this.flush(workspaceId, cacheKeyNames);
await this.recomputeDataFromProvider(workspaceId, cacheKeyNames, {
strategy: 'mint',
});
// Clear memoizer again after recomputation to evict any stale entries
// cached by concurrent getOrRecompute calls during the flush window.
await this.memoizer.clearKeys(`${workspaceId}-`);
},
);
}
public async getCacheHashes(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<Partial<Record<WorkspaceCacheKeyName, string>>> {
if (cacheKeyNames.length === 0) {
return {};
}
const hashKeys = cacheKeyNames.map(
(keyName) => `${this.buildCacheKey(workspaceId, keyName)}:hash`,
);
const hashes = await this.cacheStorage.mget<string>(hashKeys);
const result: Partial<Record<WorkspaceCacheKeyName, string>> = {};
for (const [index, keyName] of cacheKeyNames.entries()) {
if (isDefined(hashes[index])) {
result[keyName] = hashes[index];
}
}
return result;
}
public async flush(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
await this.deleteFromRedis(workspaceId, cacheKeyNames);
this.deleteFromLocalCache(workspaceId, cacheKeyNames);
}
private assertValidCacheParameters(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): void {
if (
!isDefined(workspaceId) ||
cacheKeyNames.length === 0 ||
!isValidUuid(workspaceId)
) {
throw new WorkspaceCacheException(
'Invalid parameters: workspace ID and cache key names are required',
WorkspaceCacheExceptionCode.INVALID_PARAMETERS,
);
}
}
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[];
adoptableHashes: Partial<Record<WorkspaceCacheKeyName, string>>;
}> {
const validKeys: WorkspaceCacheKeyName[] = [];
const keysNeedingDataFromRedis: WorkspaceCacheKeyName[] = [];
const keysNeedingRecompute: WorkspaceCacheKeyName[] = [];
const adoptableHashes: Partial<Record<WorkspaceCacheKeyName, string>> = {};
if (cacheKeyNames.length === 0) {
return {
validKeys,
keysNeedingDataFromRedis,
keysNeedingRecompute,
adoptableHashes,
};
}
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);
if (isDefined(redisHash)) {
adoptableHashes[keyName] = redisHash;
}
} else {
keysNeedingDataFromRedis.push(keyName);
}
}
return {
validKeys,
keysNeedingDataFromRedis,
keysNeedingRecompute,
adoptableHashes,
};
}
private async fetchDataFromRedis(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<{
redisEntries: CacheEntriesResult;
missingInRedis: WorkspaceCacheKeyName[];
}> {
const redisEntries: CacheEntriesResult = { data: {}, hashes: {} };
const missingInRedis: WorkspaceCacheKeyName[] = [];
if (cacheKeyNames.length === 0) {
return { redisEntries, 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(redisEntries.data, { [keyName]: data });
redisEntries.hashes[keyName] = hash;
this.setInLocalCache(workspaceId, keyName, data, hash);
} else {
missingInRedis.push(keyName);
}
}
return { redisEntries, missingInRedis };
}
private async recomputeDataFromProvider(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
hashResolution: RecomputeHashResolution,
): Promise<CacheEntriesResult> {
const result: CacheEntriesResult = { data: {}, hashes: {} };
if (cacheKeyNames.length === 0) {
return result;
}
const computePromises = cacheKeyNames.map(async (keyName) => {
const provider = this.getProviderOrThrow(keyName);
const isLocalDataOnly = this.localDataOnlyKeys.has(keyName);
const computeStartedAt = performance.now();
try {
const data = await Sentry.startSpan(
{
name: 'compute workspace metadata cache entry from provider',
op: 'cache.recompute',
onlyIfParent: true,
attributes: {
'cache.key_name': keyName,
'cache.recompute.strategy': hashResolution.strategy,
'cache.local_data_only': isLocalDataOnly,
},
},
() => provider.computeForCache(workspaceId),
);
if (hashResolution.strategy === 'mint') {
return { keyName, data, hash: crypto.randomUUID(), isAdopted: false };
}
const adoptableHash = hashResolution.adoptableHashes[keyName];
return {
keyName,
data,
hash: adoptableHash ?? crypto.randomUUID(),
isAdopted: isDefined(adoptableHash),
};
} finally {
this.recomputeDurationHistogram.record(
(performance.now() - computeStartedAt) / 1000,
{ cache_key: keyName },
);
}
});
const computed = await Promise.all(computePromises);
const redisEntries: Array<{ key: string; value: unknown }> = [];
const bootstrapHashEntries: Array<{ key: string; value: string }> = [];
for (const { keyName, data, hash, isAdopted } of computed) {
Object.assign(result.data, { [keyName]: data });
result.hashes[keyName] = hash;
const baseKey = this.buildCacheKey(workspaceId, keyName);
const isLocalDataOnly = this.localDataOnlyKeys.has(keyName);
const isRecoveryBootstrap =
hashResolution.strategy === 'recover' && !isAdopted && isLocalDataOnly;
if (isRecoveryBootstrap) {
bootstrapHashEntries.push({ key: `${baseKey}:hash`, value: hash });
} else if (!isAdopted) {
redisEntries.push({ key: `${baseKey}:hash`, value: hash });
}
if (!isLocalDataOnly) {
redisEntries.push({ key: `${baseKey}:data`, value: data });
}
this.setInLocalCache(workspaceId, keyName, data, hash);
}
if (redisEntries.length > 0) {
const redisWriteStartedAt = performance.now();
try {
await this.cacheStorage.mset(redisEntries);
} finally {
this.redisWriteDurationHistogram.record(
(performance.now() - redisWriteStartedAt) / 1000,
);
}
}
if (bootstrapHashEntries.length > 0) {
const bootstrapHashTtlMs =
this.twentyConfigService.get('CACHE_STORAGE_TTL') * 1000;
await Promise.all(
bootstrapHashEntries.map(({ key, value }) =>
this.cacheStorage.setIfAbsent(key, value, bootstrapHashTtlMs),
),
);
}
return result;
}
private getFromLocalCache(
workspaceId: string,
workspaceCacheKeyNames: WorkspaceCacheKeyName[],
): CacheEntriesResult {
const result: CacheEntriesResult = { data: {}, hashes: {} };
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.data, { [keyName]: version.data });
result.hashes[keyName] = entry.latestHash;
this.cleanupStaleVersions(entry);
}
}
return result;
}
private deleteFromLocalCache(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): void {
for (const keyName of cacheKeyNames) {
const localKey = this.buildCacheKey(workspaceId, keyName);
const entry = this.localCache.get(localKey);
if (isDefined(entry)) {
entry.lastHashCheckedAt = 0;
}
}
}
private async deleteFromRedis(
workspaceId: string,
cacheKeyNames: WorkspaceCacheKeyName[],
): Promise<void> {
const keysToDelete = cacheKeyNames.flatMap((keyName) => {
const baseKey = this.buildCacheKey(workspaceId, keyName);
return [`${baseKey}:data`, `${baseKey}:hash`];
});
await this.cacheStorage.mdel(keysToDelete);
}
private setInLocalCache(
workspaceId: string,
keyName: WorkspaceCacheKeyName,
data: CacheDataType,
hash: string,
): void {
const localKey = this.buildCacheKey(workspaceId, keyName);
let entry = this.localCache.get(localKey);
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();
this.cleanupStaleVersions(entry);
this.evictLRUEntriesIfNeeded();
}
private evictLRUEntriesIfNeeded(): void {
if (this.localCache.size <= MAX_LOCAL_CACHE_ENTRIES) {
return;
}
const entries = [...this.localCache.entries()].sort(
(a, b) => a[1].lastHashCheckedAt - b[1].lastHashCheckedAt,
);
const toEvict = entries.slice(
0,
Math.max(MIN_EVICT_KEYS, this.localCache.size - MAX_LOCAL_CACHE_ENTRIES),
);
for (const [key] of toEvict) {
this.localCache.delete(key);
}
this.metricsService.incrementCounterBy({
key: MetricsKeys.WorkspaceMetadataCacheLocalEviction,
amount: toEvict.length,
});
}
private cleanupStaleVersions(
entry: WorkspaceLocalCacheEntry<CacheDataType>,
): void {
const now = Date.now();
for (const [hash, version] of entry.versions) {
if (
hash !== entry.latestHash &&
now - version.lastReadAt > STALE_VERSION_TTL_MS
) {
entry.versions.delete(hash);
}
}
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);
while (
entry.versions.size >= MAX_LOCAL_STALE_VERSIONS &&
sorted.length > 0
) {
const oldestEntry = sorted.shift();
if (isDefined(oldestEntry)) {
entry.versions.delete(oldestEntry[0]);
}
}
}
}
private evictExpiredLocalEntriesIfNeeded(): void {
const now = Date.now();
if (
isDefined(this.lastLocalCacheExpirationSweepAt) &&
now - this.lastLocalCacheExpirationSweepAt <
LOCAL_CACHE_EXPIRATION_SWEEP_INTERVAL_MS
) {
return;
}
this.evictExpiredLocalEntries(now);
this.lastLocalCacheExpirationSweepAt = now;
}
private evictExpiredLocalEntries(now: number): void {
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<CacheDataType> {
const provider = this.workspaceCacheProviders.get(keyName);
if (!isDefined(provider)) {
throw new Error(`Cache provider with key name "${keyName}" not found`);
}
return provider;
}
private buildCacheKey(
workspaceId: string,
keyName: WorkspaceCacheKeyName,
): string {
return `${WORKSPACE_CACHE_KEYS_V2[keyName]}:${workspaceId}`;
}
}