perf(sse+tasks): индекс SSE-соединений, getTaskTree на recursive CTE, батчи N+1

Шаги 1.4 и 1.5 плана production-готовности:
- SseConnectionIndex (byOrg/byUser), один heartbeat-таймер, протокол SSE не тронут
- getTaskTree/getTaskParentChain: WITH RECURSIVE CTE (было 1+2N запросов)
- sync initial/delta: Promise.all по формам
- embedding queue/reindex: батч-предзагрузки вместо поштучных запросов
- 8 новых тестов (96/96)
This commit is contained in:
2026-09-07 23:29:21 +03:00
parent 9528a8123a
commit 667a321341
10 changed files with 518 additions and 121 deletions

View File

@@ -350,20 +350,92 @@ export class TasksCoreStorage extends FormsStorage {
}
async getTaskTree(taskId: number, organizationId: number): Promise<any> {
const task = await this.getTask(taskId, organizationId);
if (!task) return null;
// Один recursive CTE собирает id всего поддерева (включая корень) с tenant-фильтром,
// затем полные строки задач загружаются одним IN-запросом — вместо 1+N рекурсивных запросов.
// Лимит lvl < 100 — страховка от бесконечной рекурсии при циклическом parentTaskId в данных.
const idResult = await db.execute(sql`
WITH RECURSIVE subtree AS (
SELECT t.id, t.parent_task_id, t.position, 0 AS lvl
FROM tasks t
INNER JOIN forms f ON f.id = t.form_id
WHERE t.id = ${taskId} AND f.organization_id = ${organizationId}
UNION ALL
SELECT t.id, t.parent_task_id, t.position, s.lvl + 1
FROM tasks t
INNER JOIN forms f ON f.id = t.form_id
INNER JOIN subtree s ON t.parent_task_id = s.id
WHERE f.organization_id = ${organizationId} AND s.lvl < 100
)
SELECT id, parent_task_id AS "parentTaskId"
FROM subtree
ORDER BY lvl, position
`);
const subtasks = await this.getSubtasks(taskId, organizationId);
const subtasksWithChildren = await Promise.all(
subtasks.map(async (subtask) => {
return await this.getTaskTree(subtask.id, organizationId);
})
);
const idRows = idResult.rows as Array<{ id: number; parentTaskId: number | null }>;
if (idRows.length === 0) return null;
return {
...task,
subtasks: subtasksWithChildren.filter(Boolean)
};
// id'ы уже отфильтрованы по организации в CTE — дополнительный tenant-фильтр не нужен
const taskRows = await db
.select()
.from(tasks)
.where(inArray(tasks.id, idRows.map(r => r.id)));
const nodeById = new Map<number, any>();
for (const task of taskRows) {
nodeById.set(task.id, { ...task, subtasks: [] });
}
// Строки идут в порядке ORDER BY lvl, position — при добавлении в списки детей
// сохраняется сортировка по position внутри каждого родителя (как у getSubtasks).
// assembled защищает от повторного добавления узла при циклическом parentTaskId в данных.
const root = nodeById.get(taskId);
if (!root) return null;
const assembled = new Set<number>([taskId]);
for (const row of idRows) {
if (assembled.has(row.id)) continue;
const node = nodeById.get(row.id);
const parent = row.parentTaskId != null ? nodeById.get(row.parentTaskId) : undefined;
if (node && parent) {
parent.subtasks.push(node);
assembled.add(row.id);
}
}
return root;
}
/**
* Цепочка родителей вверх по parentTaskId (ближайший первым), максимум maxDepth уровней.
* Один recursive CTE + один IN-запрос вместо последовательных getTask на каждый уровень.
*/
async getTaskParentChain(taskId: number, organizationId: number, maxDepth = 10): Promise<Task[]> {
const idResult = await db.execute(sql`
WITH RECURSIVE ancestors AS (
SELECT t.id, t.parent_task_id, 1 AS lvl
FROM tasks t
INNER JOIN forms f ON f.id = t.form_id
WHERE t.id = (SELECT parent_task_id FROM tasks WHERE id = ${taskId})
AND f.organization_id = ${organizationId}
UNION ALL
SELECT t.id, t.parent_task_id, a.lvl + 1
FROM tasks t
INNER JOIN forms f ON f.id = t.form_id
INNER JOIN ancestors a ON t.id = a.parent_task_id
WHERE f.organization_id = ${organizationId} AND a.lvl < ${maxDepth}
)
SELECT id FROM ancestors ORDER BY lvl
`);
const ids = (idResult.rows as Array<{ id: number }>).map(r => r.id);
if (ids.length === 0) return [];
const taskRows = await db
.select()
.from(tasks)
.where(inArray(tasks.id, ids));
const byId = new Map(taskRows.map(t => [t.id, t]));
// Порядок — от ближайшего родителя вверх (по lvl из CTE)
return ids.map(id => byId.get(id)).filter((t): t is Task => !!t);
}
async updateTask(id: number, organizationId: number, updates: Partial<Task>): Promise<Task> {
@@ -464,6 +536,30 @@ export class TasksCoreStorage extends FormsStorage {
.then(results => results.map(r => r.task_field_values));
}
/** Батч-загрузка задач по списку id одним IN-запросом (с tenant-фильтром через форму) */
async getTasksByIds(taskIds: number[], organizationId: number): Promise<Task[]> {
if (taskIds.length === 0) return [];
return await db
.select()
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(tasks.id, taskIds), eq(forms.organizationId, organizationId)))
.then(results => results.map(r => r.tasks));
}
/** Батч-загрузка значений полей для списка задач одним IN-запросом (с tenant-фильтром) */
async getTaskFieldValuesByTaskIds(taskIds: number[], organizationId: number): Promise<TaskFieldValue[]> {
if (taskIds.length === 0) return [];
return await db
.select()
.from(taskFieldValues)
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(taskFieldValues.taskId, taskIds), eq(forms.organizationId, organizationId)))
.orderBy(taskFieldValues.taskId, taskFieldValues.fieldId)
.then(results => results.map(r => r.task_field_values));
}
async createTaskFieldValue(insertValue: InsertTaskFieldValue): Promise<TaskFieldValue> {
const [value] = await db
.insert(taskFieldValues)

View File

@@ -2,7 +2,7 @@ import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews
import { taskRelations } from "@shared/schema";
import { taskAssignees, type TaskAssignee } from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, exists, sql, gt } from "drizzle-orm";
import { eq, and, desc, exists, sql, gt, inArray } from "drizzle-orm";
import { formatUserName } from "../utils/formatUserName";
import { TasksCoreStorage } from "./tasks-core.storage";
import { fieldConditionsMatch, resolveFieldDisplayText } from "../utils/field-conditions";
@@ -398,6 +398,32 @@ export class TasksStorage extends TasksCoreStorage {
};
}
/** Батч-загрузка сообщений по списку id одним IN-запросом (сырые строки, tenant-фильтр) */
async getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise<TaskMessage[]> {
if (messageIds.length === 0) return [];
return await db
.select()
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(taskMessages.id, messageIds), eq(forms.organizationId, organizationId)))
.orderBy(taskMessages.createdAt)
.then(results => results.map(r => r.task_messages));
}
/** Батч-загрузка сообщений для списка задач одним IN-запросом (сырые строки, tenant-фильтр) */
async getTaskMessagesByTaskIds(taskIds: number[], organizationId: number): Promise<TaskMessage[]> {
if (taskIds.length === 0) return [];
return await db
.select()
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(taskMessages.taskId, taskIds), eq(forms.organizationId, organizationId)))
.orderBy(taskMessages.createdAt)
.then(results => results.map(r => r.task_messages));
}
async createTaskMessage(insertMessage: any, organizationId: number): Promise<any> {
const taskExists = await db
.select({ id: tasks.id })