Files
iistwin/server/utils/cache.ts
Ильяс Султанов 23c49adcd1 perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности:
- accessibleTasksCache (45с) per userId:orgId внутри storage-метода
- инвалидация в storage-слое: роли, делегирования, form access,
  assignees, createTask/updateTask, appRole пользователя
- 9 новых юнит-тестов (65/65 зелёные)
2026-09-07 22:27:33 +03:00

108 lines
3.5 KiB
TypeScript

interface CacheEntry<T> {
data: T;
expiresAt: number;
}
export class TtlCache {
private store = new Map<string, CacheEntry<unknown>>();
private defaultTtlMs: number;
private maxSize: number;
constructor(ttlSeconds = 60, maxSize = 1000) {
this.defaultTtlMs = ttlSeconds * 1000;
this.maxSize = maxSize;
}
get<T>(key: string): T | undefined {
const entry = this.store.get(key) as CacheEntry<T> | undefined;
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
// LRU: move to end on access
this.store.delete(key);
this.store.set(key, entry);
return entry.data;
}
set<T>(key: string, data: T, ttlMs?: number): void {
const effectiveTtl = ttlMs ?? this.defaultTtlMs;
// If key already exists, delete first to refresh insertion order
if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.maxSize) {
// Evict oldest entry (first in Map insertion order)
const oldestKey = this.store.keys().next().value;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
}
}
this.store.set(key, { data, expiresAt: Date.now() + effectiveTtl });
}
/** Delete all keys that start with the given prefix */
invalidate(prefix: string): void {
for (const key of Array.from(this.store.keys())) {
if (key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
/** Alias for invalidate — delete all keys starting with prefix */
invalidatePrefix(prefix: string): void {
this.invalidate(prefix);
}
/** Delete all keys that end with the given suffix */
invalidateSuffix(suffix: string): void {
for (const key of Array.from(this.store.keys())) {
if (key.endsWith(suffix)) {
this.store.delete(key);
}
}
}
/** Remove a single key */
delete(key: string): void {
this.store.delete(key);
}
/** Completely clear the cache */
clear(): void {
this.store.clear();
}
/** Current number of entries (including possibly expired ones) */
get size(): number {
return this.store.size;
}
}
export const formsCache = new TtlCache(60);
export const tasksMinimalCache = new TtlCache(60);
/**
* Кэш множеств доступных задач (getAccessibleTaskIds).
* Ключ: accessible:<userId>:<organizationId>.
* Значение: Set<number> | null (null = admin/view_all, фильтрация не нужна).
* TTL 45 сек + явная инвалидация при изменении ролей, делегирований,
* правил доступа к формам и назначений задач.
*/
export const accessibleTasksCache = new TtlCache(45);
export function accessibleTasksKey(userId: number, organizationId: number): string {
return `accessible:${userId}:${organizationId}`;
}
/** Точечная инвалидация для одного пользователя (назначение, членство в роли и т.п.) */
export function invalidateAccessibleTasksForUser(userId: number, organizationId: number): void {
accessibleTasksCache.delete(accessibleTasksKey(userId, organizationId));
}
/** Массовая инвалидация по организации (роли, правила доступа к формам и т.п.) */
export function invalidateAccessibleTasksForOrg(organizationId: number): void {
accessibleTasksCache.invalidateSuffix(`:${organizationId}`);
}