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:
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user