diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index ff679a9..dcf47d5 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -148,3 +148,17 @@ - Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch). - EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом. - Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает. + +--- + +## [0.12] getAccessibleTaskIds: кэш + инвалидация + +- Статус: ✅ done +- Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану). +- Что изменено: + - `server/utils/cache.ts` — `accessibleTasksCache` (TTL 45 сек), ключ `accessible::`, метод `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 (та же оговорка, что у существующих кэшей). diff --git a/server/services/delegation.service.ts b/server/services/delegation.service.ts index 913d58c..95635e6 100644 --- a/server/services/delegation.service.ts +++ b/server/services/delegation.service.ts @@ -11,6 +11,7 @@ import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm"; import { storage } from "../storage"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; import { logAudit } from "../utils/audit"; +import { invalidateAccessibleTasksForUser } from "../utils/cache"; /** * Сервис замещения пользователей (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); if (activeDelegateId !== row.userId) { await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id)); + invalidateAccessibleTasksForUser(row.userId, organizationId); } } } catch (err) { @@ -360,6 +362,9 @@ export async function createDelegation(data: { createdBy: data.createdBy, }) .returning(); + // Замещение меняет доступ заместителя (и потенциально отсутствующего) + invalidateAccessibleTasksForUser(data.userId, data.organizationId); + invalidateAccessibleTasksForUser(data.delegateUserId, data.organizationId); return { conflict: false, delegation }; } @@ -380,6 +385,10 @@ export async function updateDelegation( ) ) .returning(); + if (updated) { + invalidateAccessibleTasksForUser(userId, organizationId); + invalidateAccessibleTasksForUser(updated.delegateUserId, organizationId); + } return updated ?? null; } @@ -397,7 +406,11 @@ export async function deleteDelegation( 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; } diff --git a/server/storage/forms.storage.ts b/server/storage/forms.storage.ts index d701264..e4976ac 100644 --- a/server/storage/forms.storage.ts +++ b/server/storage/forms.storage.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { eq, and, desc, exists, sql } from "drizzle-orm"; import crypto from "crypto"; import { UserProfileStorage } from "./user-profile.storage"; +import { invalidateAccessibleTasksForOrg } from "../utils/cache"; export class FormsStorage extends UserProfileStorage { // Forms async getForm(id: number, organizationId: number): Promise
{ @@ -35,6 +36,10 @@ export class FormsStorage extends UserProfileStorage { .set({ ...updates, updatedAt: new Date() }) .where(and(eq(forms.id, id), eq(forms.organizationId, organizationId))) .returning(); + // visibility/createdBy меняют доступ к задачам формы — сброс кэша по организации + if ('visibility' in updates || 'createdBy' in updates) { + invalidateAccessibleTasksForOrg(organizationId); + } return form; } diff --git a/server/storage/roles.storage.ts b/server/storage/roles.storage.ts index 0783299..9b48b87 100644 --- a/server/storage/roles.storage.ts +++ b/server/storage/roles.storage.ts @@ -2,6 +2,7 @@ import { users, appRoles, permissions, appRolePermissions, roleMembers, roles } import { db } from "../db"; import { eq, and, asc, inArray } from "drizzle-orm"; import { BillingStorage } from "./billing.storage"; +import { invalidateAccessibleTasksForUser, invalidateAccessibleTasksForOrg } from "../utils/cache"; // In-memory permission cache let _permCache: Map | null = null; @@ -163,6 +164,10 @@ export class RolesStorage extends BillingStorage { if (!manager) throw new Error('Руководитель не найден в этой организации'); } 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; } @@ -170,6 +175,7 @@ export class RolesStorage extends BillingStorage { 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.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 }[]> { @@ -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))); if (!targetUser) throw new Error('Пользователь не найден в этой организации'); await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing(); + invalidateAccessibleTasksForUser(userId, organizationId); } 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))); if (!targetUser) throw new Error('Пользователь не найден в этой организации'); 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) { @@ -247,6 +255,7 @@ export class RolesStorage extends BillingStorage { if (targetRoleId !== null) { await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing(); } + invalidateAccessibleTasksForUser(userId, organizationId); } async getUserRoles(userId: number, organizationId: number) { @@ -343,6 +352,11 @@ export class RolesStorage extends BillingStorage { const values = roleIds.map(roleId => ({ userId, roleId })); 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) { diff --git a/server/storage/system.storage.ts b/server/storage/system.storage.ts index a7dee02..a4f5c69 100644 --- a/server/storage/system.storage.ts +++ b/server/storage/system.storage.ts @@ -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 { db } from "../db"; import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm"; +import { + accessibleTasksCache, + accessibleTasksKey, + invalidateAccessibleTasksForUser, + invalidateAccessibleTasksForOrg, +} from "../utils/cache"; import { RolesStorage } from "./roles.storage"; export class SystemStorage extends RolesStorage { // ===================== @@ -37,6 +43,8 @@ export class SystemStorage extends RolesStorage { .limit(1); if (existing.length > 0) return existing[0]; const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning(); + // Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем») + invalidateAccessibleTasksForOrg(organizationId); return row; } @@ -46,6 +54,7 @@ export class SystemStorage extends RolesStorage { eq(taskRoles.roleId, roleId), eq(taskRoles.organizationId, organizationId), )); + invalidateAccessibleTasksForOrg(organizationId); } async addTaskAssignee( @@ -75,6 +84,8 @@ export class SystemStorage extends RolesStorage { delegatedFromUserId: options?.delegatedFromUserId ?? null, pendingReview: options?.pendingReview ?? false, }).returning(); + // Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа + invalidateAccessibleTasksForUser(userId, organizationId); // Хук замещения: прямое назначение резолвит активного заместителя. // Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию. // Динамический import разрывает цикл storage → delegation.service → storage. @@ -92,6 +103,7 @@ export class SystemStorage extends RolesStorage { eq(taskAssignees.userId, userId), eq(taskAssignees.organizationId, organizationId), )); + invalidateAccessibleTasksForUser(userId, organizationId); } // ===================== @@ -136,6 +148,8 @@ export class SystemStorage extends RolesStorage { grantedVia, roleId: roleId ?? null, }).onConflictDoNothing(); + // Первая фиксация доступа к задаче — сбрасываем кэш пользователя + invalidateAccessibleTasksForUser(userId, organizationId); } async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise { @@ -147,6 +161,7 @@ export class SystemStorage extends RolesStorage { await db.update(taskUserAccess) .set({ revokedAt: null, grantedVia }) .where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId))); + invalidateAccessibleTasksForUser(userId, organizationId); } else { await db.insert(taskUserAccess).values({ taskId, @@ -155,6 +170,7 @@ export class SystemStorage extends RolesStorage { grantedVia, roleId: null, }).onConflictDoNothing(); + invalidateAccessibleTasksForUser(userId, organizationId); } } @@ -167,6 +183,7 @@ export class SystemStorage extends RolesStorage { eq(taskUserAccess.organizationId, organizationId), isNull(taskUserAccess.revokedAt), )); + invalidateAccessibleTasksForUser(userId, organizationId); } 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], 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). */ async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise | null> { + // TTL-кэш: горячий путь списков задач. null (view_all) тоже кэшируется — + // TtlCache.get возвращает undefined только при промахе. + const cacheKey = accessibleTasksKey(userId, organizationId); + const cached = accessibleTasksCache.get | null>(cacheKey); + if (cached !== undefined) return cached; + 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` SELECT DISTINCT t.id FROM tasks t @@ -304,6 +331,7 @@ export class SystemStorage extends RolesStorage { for (const row of result.rows as { id: number }[]) { ids.add(Number(row.id)); } + accessibleTasksCache.set(cacheKey, ids); return ids; } @@ -413,6 +441,7 @@ export class SystemStorage extends RolesStorage { async createFormAccessRule(data: InsertFormAccessRule) { const [row] = await db.insert(formAccessRules).values(data).returning(); + invalidateAccessibleTasksForOrg(data.organizationId); return row; } @@ -421,18 +450,22 @@ export class SystemStorage extends RolesStorage { .set(updates) .where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId))) .returning(); + invalidateAccessibleTasksForOrg(organizationId); return row; } async deleteFormAccessRule(id: number, organizationId: number) { await db.delete(formAccessRules) .where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId))); + invalidateAccessibleTasksForOrg(organizationId); } async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) { await db.update(forms) .set({ createdBy: newAuthorId }) .where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))); + // Автор формы всегда имеет доступ — смена автора меняет доступ обоих пользователей + invalidateAccessibleTasksForOrg(organizationId); } async canUserAccessForm( diff --git a/server/storage/tasks-core.storage.ts b/server/storage/tasks-core.storage.ts index fa1fb87..54642e9 100644 --- a/server/storage/tasks-core.storage.ts +++ b/server/storage/tasks-core.storage.ts @@ -6,6 +6,7 @@ import { eq, and, or, desc, asc, exists, sql, ilike, inArray, lt, gte, isNull } import { formatUserName } from "../utils/formatUserName"; import { FormsStorage } from "./forms.storage"; import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination"; +import { invalidateAccessibleTasksForUser } from "../utils/cache"; export class TasksCoreStorage extends FormsStorage { // Tasks async getTask(id: number, organizationId: number): Promise { @@ -327,6 +328,11 @@ export class TasksCoreStorage extends FormsStorage { position, }) .returning(); + // Создатель и ответственный получают доступ к новой задаче — сброс их кэша + if (task.createdBy) invalidateAccessibleTasksForUser(task.createdBy, task.organizationId); + if (task.assignedTo && task.assignedTo !== task.createdBy) { + invalidateAccessibleTasksForUser(task.assignedTo, task.organizationId); + } 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)))) )) .returning(); + // Переназначение ответственного даёт доступ новому исполнителю + if ('assignedTo' in updates && updates.assignedTo != null) { + invalidateAccessibleTasksForUser(updates.assignedTo, organizationId); + } return task; } diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index 42cdb98..657fd59 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm"; import crypto from "crypto"; import { userStatusesStorage } from "./user-statuses.storage"; +import { invalidateAccessibleTasksForUser } from "../utils/cache"; export interface ListUsersOptions { search?: string; @@ -270,6 +271,10 @@ export class UsersStorage { .set({ ...updates, updatedAt: new Date() }) .where(eq(users.id, id)) .returning(); + // Смена appRole (например, получение tasks.view_all) меняет набор доступных задач + if ('appRole' in updates && user?.organizationId) { + invalidateAccessibleTasksForUser(id, user.organizationId); + } return user; } diff --git a/server/utils/cache.ts b/server/utils/cache.ts index 44549d9..69438ef 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -55,6 +55,15 @@ export class TtlCache { 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); @@ -73,3 +82,26 @@ export class TtlCache { export const formsCache = new TtlCache(60); export const tasksMinimalCache = new TtlCache(60); + +/** + * Кэш множеств доступных задач (getAccessibleTaskIds). + * Ключ: accessible::. + * Значение: Set | 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}`); +} diff --git a/tests/accessible-tasks-cache.test.ts b/tests/accessible-tasks-cache.test.ts new file mode 100644 index 0000000..f9c1a53 --- /dev/null +++ b/tests/accessible-tasks-cache.test.ts @@ -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>('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::', () => { + 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>(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>(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(); + }); +});