- Skip module-level integration cache for multi-instance (REDIS_IS_EXTERNAL) - Add invalidateBoardCache to permission mutations (prevents future leak) - Remove unused cacheTags.integrationData - Replace KEYS with SCAN in Redis cache handler refreshTags() - Document REDIS_CACHE_DB env var
85 lines
2.1 KiB
TypeScript
85 lines
2.1 KiB
TypeScript
// ponytail: module-level cache is single-instance only.
|
|
// Multi-instance deployments (REDIS_IS_EXTERNAL=true) bypass this and rely on
|
|
// the Redis-backed Next cache handler for cross-instance coordination.
|
|
const isMultiInstance = process.env.REDIS_IS_EXTERNAL === "true";
|
|
|
|
interface CachedEntry<T> {
|
|
data: T;
|
|
timestamp: number;
|
|
pending?: Promise<T>;
|
|
}
|
|
|
|
const cache = new Map<string, CachedEntry<unknown>>();
|
|
const DEFAULT_TTL_MS = 60_000;
|
|
const MAX_ENTRIES = 200;
|
|
|
|
const cacheKey = (integrationId: string, queryKey: string) => `${integrationId}:${queryKey}`;
|
|
|
|
export const getCachedIntegrationData = async <T>(
|
|
integrationId: string,
|
|
queryKey: string,
|
|
fetcher: () => Promise<T>,
|
|
ttlMs = DEFAULT_TTL_MS,
|
|
): Promise<T> => {
|
|
if (isMultiInstance) {
|
|
return fetcher();
|
|
}
|
|
|
|
const key = cacheKey(integrationId, queryKey);
|
|
const existing = cache.get(key) as CachedEntry<T> | undefined;
|
|
const now = Date.now();
|
|
|
|
if (existing) {
|
|
if (now - existing.timestamp < ttlMs) {
|
|
return existing.data;
|
|
}
|
|
if (existing.pending) {
|
|
return existing.pending;
|
|
}
|
|
}
|
|
|
|
const pending = fetcher()
|
|
.then((data) => {
|
|
cache.set(key, { data, timestamp: Date.now() });
|
|
|
|
if (cache.size > MAX_ENTRIES) {
|
|
let oldest: string | undefined;
|
|
let oldestTime = Infinity;
|
|
for (const [k, v] of cache) {
|
|
if (v.timestamp < oldestTime) {
|
|
oldestTime = v.timestamp;
|
|
oldest = k;
|
|
}
|
|
}
|
|
if (oldest) cache.delete(oldest);
|
|
}
|
|
|
|
return data;
|
|
})
|
|
.catch((error) => {
|
|
const entry = cache.get(key) as CachedEntry<T> | undefined;
|
|
if (entry) {
|
|
delete entry.pending;
|
|
if (entry.timestamp > 0) return entry.data;
|
|
}
|
|
cache.delete(key);
|
|
throw error;
|
|
});
|
|
|
|
if (existing) {
|
|
existing.pending = pending;
|
|
return existing.data;
|
|
}
|
|
|
|
cache.set(key, { data: undefined as T, timestamp: 0, pending });
|
|
return pending;
|
|
};
|
|
|
|
export const invalidateIntegrationDataCache = (integrationId: string) => {
|
|
for (const [key] of cache) {
|
|
if (key.startsWith(`${integrationId}:`)) {
|
|
cache.delete(key);
|
|
}
|
|
}
|
|
};
|