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

@@ -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<void> {
@@ -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<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']);
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(