perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией

Шаг 0.12 плана production-готовности:
- accessibleTasksCache (45с) per userId:orgId внутри storage-метода
- инвалидация в storage-слое: роли, делегирования, form access,
  assignees, createTask/updateTask, appRole пользователя
- 9 новых юнит-тестов (65/65 зелёные)
This commit is contained in:
2026-09-07 22:27:33 +03:00
parent 82d577c797
commit 23c49adcd1
9 changed files with 231 additions and 2 deletions

View File

@@ -148,3 +148,17 @@
- Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch). - Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch).
- EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом. - EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом.
- Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает. - Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает.
---
## [0.12] getAccessibleTaskIds: кэш + инвалидация
- Статус: ✅ done
- Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану).
- Что изменено:
- `server/utils/cache.ts` — `accessibleTasksCache` (TTL 45 сек), ключ `accessible:<userId>:<orgId>`, метод `invalidateSuffix`, хелперы точечной (per-user) и org-wide инвалидации.
- `server/storage/system.storage.ts` — кэш внутри `getAccessibleTaskIds` (все caller'ы получают его автоматически, код роутов не тронут); null (admin/view_all) кэшируется отдельно от miss.
- Инвалидация проставлена в storage-слое (покрывает роуты и MCP): assignees/roles/formAccess/delegation/form visibility/updateUser appRole/createTask/updateTask assignedTo.
- Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user).
- Влияние на поиск/UX: нет (форматы ответов не тронуты).
- Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей).

View File

@@ -11,6 +11,7 @@ import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm";
import { storage } from "../storage"; import { storage } from "../storage";
import { notifyTaskAssigned } from "../utils/notifyAssignee"; import { notifyTaskAssigned } from "../utils/notifyAssignee";
import { logAudit } from "../utils/audit"; import { logAudit } from "../utils/audit";
import { invalidateAccessibleTasksForUser } from "../utils/cache";
/** /**
* Сервис замещения пользователей (delegation/substitution, референс — Pyrus). * Сервис замещения пользователей (delegation/substitution, референс — Pyrus).
@@ -279,6 +280,7 @@ export async function cleanupStaleDelegates(taskId: number, organizationId: numb
const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId); const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId);
if (activeDelegateId !== row.userId) { if (activeDelegateId !== row.userId) {
await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id)); await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id));
invalidateAccessibleTasksForUser(row.userId, organizationId);
} }
} }
} catch (err) { } catch (err) {
@@ -360,6 +362,9 @@ export async function createDelegation(data: {
createdBy: data.createdBy, createdBy: data.createdBy,
}) })
.returning(); .returning();
// Замещение меняет доступ заместителя (и потенциально отсутствующего)
invalidateAccessibleTasksForUser(data.userId, data.organizationId);
invalidateAccessibleTasksForUser(data.delegateUserId, data.organizationId);
return { conflict: false, delegation }; return { conflict: false, delegation };
} }
@@ -380,6 +385,10 @@ export async function updateDelegation(
) )
) )
.returning(); .returning();
if (updated) {
invalidateAccessibleTasksForUser(userId, organizationId);
invalidateAccessibleTasksForUser(updated.delegateUserId, organizationId);
}
return updated ?? null; return updated ?? null;
} }
@@ -397,7 +406,11 @@ export async function deleteDelegation(
eq(userDelegations.organizationId, organizationId) eq(userDelegations.organizationId, organizationId)
) )
) )
.returning({ id: userDelegations.id }); .returning({ id: userDelegations.id, delegateUserId: userDelegations.delegateUserId });
if (deleted.length > 0) {
invalidateAccessibleTasksForUser(userId, organizationId);
invalidateAccessibleTasksForUser(deleted[0].delegateUserId, organizationId);
}
return deleted.length > 0; return deleted.length > 0;
} }

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { eq, and, desc, exists, sql } from "drizzle-orm"; import { eq, and, desc, exists, sql } from "drizzle-orm";
import crypto from "crypto"; import crypto from "crypto";
import { UserProfileStorage } from "./user-profile.storage"; import { UserProfileStorage } from "./user-profile.storage";
import { invalidateAccessibleTasksForOrg } from "../utils/cache";
export class FormsStorage extends UserProfileStorage { export class FormsStorage extends UserProfileStorage {
// Forms // Forms
async getForm(id: number, organizationId: number): Promise<Form | undefined> { async getForm(id: number, organizationId: number): Promise<Form | undefined> {
@@ -35,6 +36,10 @@ export class FormsStorage extends UserProfileStorage {
.set({ ...updates, updatedAt: new Date() }) .set({ ...updates, updatedAt: new Date() })
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId))) .where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)))
.returning(); .returning();
// visibility/createdBy меняют доступ к задачам формы — сброс кэша по организации
if ('visibility' in updates || 'createdBy' in updates) {
invalidateAccessibleTasksForOrg(organizationId);
}
return form; return form;
} }

View File

@@ -2,6 +2,7 @@ import { users, appRoles, permissions, appRolePermissions, roleMembers, roles }
import { db } from "../db"; import { db } from "../db";
import { eq, and, asc, inArray } from "drizzle-orm"; import { eq, and, asc, inArray } from "drizzle-orm";
import { BillingStorage } from "./billing.storage"; import { BillingStorage } from "./billing.storage";
import { invalidateAccessibleTasksForUser, invalidateAccessibleTasksForOrg } from "../utils/cache";
// In-memory permission cache // In-memory permission cache
let _permCache: Map<string, string[]> | null = null; let _permCache: Map<string, string[]> | null = null;
@@ -163,6 +164,10 @@ export class RolesStorage extends BillingStorage {
if (!manager) throw new Error('Руководитель не найден в этой организации'); if (!manager) throw new Error('Руководитель не найден в этой организации');
} }
const [role] = await db.update(roles).set(updates).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))).returning(); const [role] = await db.update(roles).set(updates).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))).returning();
// Смена parentId меняет наследование доступа по иерархии ролей — сброс по организации
if ('parentId' in updates) {
invalidateAccessibleTasksForOrg(organizationId);
}
return role; return role;
} }
@@ -170,6 +175,7 @@ export class RolesStorage extends BillingStorage {
const { roles } = await import('@shared/schema'); const { roles } = await import('@shared/schema');
await db.update(roles).set({ parentId: null }).where(and(eq(roles.parentId, id), eq(roles.organizationId, organizationId))); await db.update(roles).set({ parentId: null }).where(and(eq(roles.parentId, id), eq(roles.organizationId, organizationId)));
await db.delete(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))); await db.delete(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId)));
invalidateAccessibleTasksForOrg(organizationId);
} }
async getRoleMembersByRole(roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> { async getRoleMembersByRole(roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> {
@@ -217,6 +223,7 @@ export class RolesStorage extends BillingStorage {
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId))); const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
if (!targetUser) throw new Error('Пользователь не найден в этой организации'); if (!targetUser) throw new Error('Пользователь не найден в этой организации');
await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing(); await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing();
invalidateAccessibleTasksForUser(userId, organizationId);
} }
async removeRoleMember(roleId: number, userId: number, organizationId: number) { async removeRoleMember(roleId: number, userId: number, organizationId: number) {
@@ -226,6 +233,7 @@ export class RolesStorage extends BillingStorage {
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId))); const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
if (!targetUser) throw new Error('Пользователь не найден в этой организации'); if (!targetUser) throw new Error('Пользователь не найден в этой организации');
await db.delete(roleMembers).where(and(eq(roleMembers.roleId, roleId), eq(roleMembers.userId, userId))); await db.delete(roleMembers).where(and(eq(roleMembers.roleId, roleId), eq(roleMembers.userId, userId)));
invalidateAccessibleTasksForUser(userId, organizationId);
} }
async moveRoleMember(sourceRoleId: number, targetRoleId: number | null, userId: number, organizationId: number) { async moveRoleMember(sourceRoleId: number, targetRoleId: number | null, userId: number, organizationId: number) {
@@ -247,6 +255,7 @@ export class RolesStorage extends BillingStorage {
if (targetRoleId !== null) { if (targetRoleId !== null) {
await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing(); await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing();
} }
invalidateAccessibleTasksForUser(userId, organizationId);
} }
async getUserRoles(userId: number, organizationId: number) { async getUserRoles(userId: number, organizationId: number) {
@@ -343,6 +352,11 @@ export class RolesStorage extends BillingStorage {
const values = roleIds.map(roleId => ({ userId, roleId })); const values = roleIds.map(roleId => ({ userId, roleId }));
await db.insert(roleMembers).values(values).onConflictDoNothing(); await db.insert(roleMembers).values(values).onConflictDoNothing();
} }
// organizationId не приходит аргументом — сбрасываем кэш по всем организациям пользователя
const userRows = await db.select({ organizationId: users.organizationId }).from(users).where(eq(users.id, userId)).limit(1);
if (userRows[0]?.organizationId) {
invalidateAccessibleTasksForUser(userId, userRows[0].organizationId);
}
} }
async getUserOrganizationalRoles(userId: number, organizationId: number) { async getUserOrganizationalRoles(userId: number, organizationId: number) {

View File

@@ -5,6 +5,12 @@ import { llmProviders, type LlmProvider, type InsertLlmProvider } from "@shared/
import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee, formAccessRules, type FormAccessRule, type InsertFormAccessRule } from "@shared/schema"; import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee, formAccessRules, type FormAccessRule, type InsertFormAccessRule } from "@shared/schema";
import { db } from "../db"; import { db } from "../db";
import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm"; import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm";
import {
accessibleTasksCache,
accessibleTasksKey,
invalidateAccessibleTasksForUser,
invalidateAccessibleTasksForOrg,
} from "../utils/cache";
import { RolesStorage } from "./roles.storage"; import { RolesStorage } from "./roles.storage";
export class SystemStorage extends RolesStorage { export class SystemStorage extends RolesStorage {
// ===================== // =====================
@@ -37,6 +43,8 @@ export class SystemStorage extends RolesStorage {
.limit(1); .limit(1);
if (existing.length > 0) return existing[0]; if (existing.length > 0) return existing[0];
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning(); const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
// Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем»)
invalidateAccessibleTasksForOrg(organizationId);
return row; return row;
} }
@@ -46,6 +54,7 @@ export class SystemStorage extends RolesStorage {
eq(taskRoles.roleId, roleId), eq(taskRoles.roleId, roleId),
eq(taskRoles.organizationId, organizationId), eq(taskRoles.organizationId, organizationId),
)); ));
invalidateAccessibleTasksForOrg(organizationId);
} }
async addTaskAssignee( async addTaskAssignee(
@@ -75,6 +84,8 @@ export class SystemStorage extends RolesStorage {
delegatedFromUserId: options?.delegatedFromUserId ?? null, delegatedFromUserId: options?.delegatedFromUserId ?? null,
pendingReview: options?.pendingReview ?? false, pendingReview: options?.pendingReview ?? false,
}).returning(); }).returning();
// Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа
invalidateAccessibleTasksForUser(userId, organizationId);
// Хук замещения: прямое назначение резолвит активного заместителя. // Хук замещения: прямое назначение резолвит активного заместителя.
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию. // Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
// Динамический import разрывает цикл storage → delegation.service → storage. // Динамический import разрывает цикл storage → delegation.service → storage.
@@ -92,6 +103,7 @@ export class SystemStorage extends RolesStorage {
eq(taskAssignees.userId, userId), eq(taskAssignees.userId, userId),
eq(taskAssignees.organizationId, organizationId), eq(taskAssignees.organizationId, organizationId),
)); ));
invalidateAccessibleTasksForUser(userId, organizationId);
} }
// ===================== // =====================
@@ -136,6 +148,8 @@ export class SystemStorage extends RolesStorage {
grantedVia, grantedVia,
roleId: roleId ?? null, roleId: roleId ?? null,
}).onConflictDoNothing(); }).onConflictDoNothing();
// Первая фиксация доступа к задаче — сбрасываем кэш пользователя
invalidateAccessibleTasksForUser(userId, organizationId);
} }
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> { async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
@@ -147,6 +161,7 @@ export class SystemStorage extends RolesStorage {
await db.update(taskUserAccess) await db.update(taskUserAccess)
.set({ revokedAt: null, grantedVia }) .set({ revokedAt: null, grantedVia })
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId))); .where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
invalidateAccessibleTasksForUser(userId, organizationId);
} else { } else {
await db.insert(taskUserAccess).values({ await db.insert(taskUserAccess).values({
taskId, taskId,
@@ -155,6 +170,7 @@ export class SystemStorage extends RolesStorage {
grantedVia, grantedVia,
roleId: null, roleId: null,
}).onConflictDoNothing(); }).onConflictDoNothing();
invalidateAccessibleTasksForUser(userId, organizationId);
} }
} }
@@ -167,6 +183,7 @@ export class SystemStorage extends RolesStorage {
eq(taskUserAccess.organizationId, organizationId), eq(taskUserAccess.organizationId, organizationId),
isNull(taskUserAccess.revokedAt), isNull(taskUserAccess.revokedAt),
)); ));
invalidateAccessibleTasksForUser(userId, organizationId);
} }
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> { async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
@@ -220,6 +237,7 @@ export class SystemStorage extends RolesStorage {
target: [taskUserAccess.taskId, taskUserAccess.userId], target: [taskUserAccess.taskId, taskUserAccess.userId],
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId }, set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
}); });
invalidateAccessibleTasksForUser(userId, organizationId);
} }
} }
@@ -228,8 +246,17 @@ export class SystemStorage extends RolesStorage {
* Returns null for admins (all tasks accessible — skip filtering). * Returns null for admins (all tasks accessible — skip filtering).
*/ */
async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise<Set<number> | null> { async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise<Set<number> | null> {
// TTL-кэш: горячий путь списков задач. null (view_all) тоже кэшируется —
// TtlCache.get возвращает undefined только при промахе.
const cacheKey = accessibleTasksKey(userId, organizationId);
const cached = accessibleTasksCache.get<Set<number> | null>(cacheKey);
if (cached !== undefined) return cached;
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']); const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
if (hasViewAll) return null; if (hasViewAll) {
accessibleTasksCache.set(cacheKey, null);
return null;
}
const result = await db.execute(sql` const result = await db.execute(sql`
SELECT DISTINCT t.id SELECT DISTINCT t.id
FROM tasks t FROM tasks t
@@ -304,6 +331,7 @@ export class SystemStorage extends RolesStorage {
for (const row of result.rows as { id: number }[]) { for (const row of result.rows as { id: number }[]) {
ids.add(Number(row.id)); ids.add(Number(row.id));
} }
accessibleTasksCache.set(cacheKey, ids);
return ids; return ids;
} }
@@ -413,6 +441,7 @@ export class SystemStorage extends RolesStorage {
async createFormAccessRule(data: InsertFormAccessRule) { async createFormAccessRule(data: InsertFormAccessRule) {
const [row] = await db.insert(formAccessRules).values(data).returning(); const [row] = await db.insert(formAccessRules).values(data).returning();
invalidateAccessibleTasksForOrg(data.organizationId);
return row; return row;
} }
@@ -421,18 +450,22 @@ export class SystemStorage extends RolesStorage {
.set(updates) .set(updates)
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId))) .where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)))
.returning(); .returning();
invalidateAccessibleTasksForOrg(organizationId);
return row; return row;
} }
async deleteFormAccessRule(id: number, organizationId: number) { async deleteFormAccessRule(id: number, organizationId: number) {
await db.delete(formAccessRules) await db.delete(formAccessRules)
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId))); .where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)));
invalidateAccessibleTasksForOrg(organizationId);
} }
async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) { async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) {
await db.update(forms) await db.update(forms)
.set({ createdBy: newAuthorId }) .set({ createdBy: newAuthorId })
.where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))); .where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)));
// Автор формы всегда имеет доступ — смена автора меняет доступ обоих пользователей
invalidateAccessibleTasksForOrg(organizationId);
} }
async canUserAccessForm( async canUserAccessForm(

View File

@@ -6,6 +6,7 @@ import { eq, and, or, desc, asc, exists, sql, ilike, inArray, lt, gte, isNull }
import { formatUserName } from "../utils/formatUserName"; import { formatUserName } from "../utils/formatUserName";
import { FormsStorage } from "./forms.storage"; import { FormsStorage } from "./forms.storage";
import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination"; import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination";
import { invalidateAccessibleTasksForUser } from "../utils/cache";
export class TasksCoreStorage extends FormsStorage { export class TasksCoreStorage extends FormsStorage {
// Tasks // Tasks
async getTask(id: number, organizationId: number): Promise<Task | undefined> { async getTask(id: number, organizationId: number): Promise<Task | undefined> {
@@ -327,6 +328,11 @@ export class TasksCoreStorage extends FormsStorage {
position, position,
}) })
.returning(); .returning();
// Создатель и ответственный получают доступ к новой задаче — сброс их кэша
if (task.createdBy) invalidateAccessibleTasksForUser(task.createdBy, task.organizationId);
if (task.assignedTo && task.assignedTo !== task.createdBy) {
invalidateAccessibleTasksForUser(task.assignedTo, task.organizationId);
}
return task; return task;
} }
@@ -369,6 +375,10 @@ export class TasksCoreStorage extends FormsStorage {
exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId)))) exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId))))
)) ))
.returning(); .returning();
// Переназначение ответственного даёт доступ новому исполнителю
if ('assignedTo' in updates && updates.assignedTo != null) {
invalidateAccessibleTasksForUser(updates.assignedTo, organizationId);
}
return task; return task;
} }

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm"; import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
import crypto from "crypto"; import crypto from "crypto";
import { userStatusesStorage } from "./user-statuses.storage"; import { userStatusesStorage } from "./user-statuses.storage";
import { invalidateAccessibleTasksForUser } from "../utils/cache";
export interface ListUsersOptions { export interface ListUsersOptions {
search?: string; search?: string;
@@ -270,6 +271,10 @@ export class UsersStorage {
.set({ ...updates, updatedAt: new Date() }) .set({ ...updates, updatedAt: new Date() })
.where(eq(users.id, id)) .where(eq(users.id, id))
.returning(); .returning();
// Смена appRole (например, получение tasks.view_all) меняет набор доступных задач
if ('appRole' in updates && user?.organizationId) {
invalidateAccessibleTasksForUser(id, user.organizationId);
}
return user; return user;
} }

View File

@@ -55,6 +55,15 @@ export class TtlCache {
this.invalidate(prefix); 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 */ /** Remove a single key */
delete(key: string): void { delete(key: string): void {
this.store.delete(key); this.store.delete(key);
@@ -73,3 +82,26 @@ export class TtlCache {
export const formsCache = new TtlCache(60); export const formsCache = new TtlCache(60);
export const tasksMinimalCache = 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}`);
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
TtlCache,
accessibleTasksCache,
accessibleTasksKey,
invalidateAccessibleTasksForUser,
invalidateAccessibleTasksForOrg,
} from '../server/utils/cache';
describe('TtlCache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('возвращает значение в пределах TTL и undefined после истечения', () => {
const cache = new TtlCache(30);
cache.set('k', new Set([1, 2, 3]));
expect(cache.get<Set<number>>('k')?.has(2)).toBe(true);
vi.advanceTimersByTime(29_000);
expect(cache.get('k')).toBeDefined();
vi.advanceTimersByTime(2_000);
expect(cache.get('k')).toBeUndefined();
});
it('кэширует null как значение (отличая от промаха undefined)', () => {
const cache = new TtlCache(30);
cache.set('admin', null);
expect(cache.get('admin')).toBeNull();
expect(cache.get('missing')).toBeUndefined();
});
it('invalidatePrefix удаляет ключи по префиксу', () => {
const cache = new TtlCache(30);
cache.set('tasks:1:minimal:a', 1);
cache.set('tasks:1:minimal:b', 2);
cache.set('tasks:2:minimal:a', 3);
cache.invalidatePrefix('tasks:1:minimal:');
expect(cache.get('tasks:1:minimal:a')).toBeUndefined();
expect(cache.get('tasks:1:minimal:b')).toBeUndefined();
expect(cache.get('tasks:2:minimal:a')).toBe(3);
});
it('invalidateSuffix удаляет ключи по суффиксу', () => {
const cache = new TtlCache(30);
cache.set('accessible:5:1', 'a');
cache.set('accessible:7:1', 'b');
cache.set('accessible:5:2', 'c');
cache.invalidateSuffix(':1');
expect(cache.get('accessible:5:1')).toBeUndefined();
expect(cache.get('accessible:7:1')).toBeUndefined();
expect(cache.get('accessible:5:2')).toBe('c');
});
it('вытесняет самый старый ключ при переполнении maxSize', () => {
const cache = new TtlCache(30, 2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
});
});
describe('accessibleTasksCache helpers', () => {
beforeEach(() => {
accessibleTasksCache.clear();
});
it('ключ имеет формат accessible:<userId>:<organizationId>', () => {
expect(accessibleTasksKey(12, 3)).toBe('accessible:12:3');
});
it('точечная инвалидация сбрасывает только одного пользователя', () => {
accessibleTasksCache.set(accessibleTasksKey(1, 1), new Set([10]));
accessibleTasksCache.set(accessibleTasksKey(2, 1), new Set([20]));
invalidateAccessibleTasksForUser(1, 1);
expect(accessibleTasksCache.get(accessibleTasksKey(1, 1))).toBeUndefined();
expect(accessibleTasksCache.get<Set<number>>(accessibleTasksKey(2, 1))?.has(20)).toBe(true);
});
it('инвалидация по организации сбрасывает всех её пользователей, не трогая другие организации', () => {
accessibleTasksCache.set(accessibleTasksKey(1, 1), new Set([10]));
accessibleTasksCache.set(accessibleTasksKey(2, 1), null);
accessibleTasksCache.set(accessibleTasksKey(1, 2), new Set([30]));
invalidateAccessibleTasksForOrg(1);
expect(accessibleTasksCache.get(accessibleTasksKey(1, 1))).toBeUndefined();
expect(accessibleTasksCache.get(accessibleTasksKey(2, 1))).toBeUndefined();
expect(accessibleTasksCache.get<Set<number>>(accessibleTasksKey(1, 2))?.has(30)).toBe(true);
});
it('null (admin/view_all) кэшируется и корректно читается', () => {
accessibleTasksCache.set(accessibleTasksKey(9, 1), null);
expect(accessibleTasksCache.get(accessibleTasksKey(9, 1))).toBeNull();
expect(accessibleTasksCache.get(accessibleTasksKey(9, 2))).toBeUndefined();
});
});