оптимизация довеска

This commit is contained in:
gorbenko.pavel
2026-08-18 18:08:03 +05:00
parent b972879314
commit 80d8790ae1
@@ -6,6 +6,10 @@ import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
// sp.batched() — довесок по свежести уходит одним запросом на сайт, см.
// fetchRecentApprovalRows. Импорт side-effect'ный: без него метод есть в типах,
// но в рантайме не зарегистрирован.
import '@pnp/sp/batching';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
@@ -219,15 +223,29 @@ async function buildPendingIndex(
}
});
const hitsByTarget =
// Два источника кандидатов — Search (весь архив) и REST-довесок по свежести
// (то, что индекс мог не успеть подхватить) — независимы друг от друга,
// поэтому идут параллельно, а не один за другим.
const [hitsByTarget, recentByTarget] = await Promise.all([
okEntries.length > 0
? await traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
: new Map<string, number[]>();
? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
: Promise.resolve(new Map<string, number[]>()),
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
okEntries.length > 0 && scope === 'myTurn'
? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses))
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
]);
const parts = await Promise.all(
okEntries.map((entry) =>
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
finalizeApprovalTarget(context, entry, scope, statuses, hitsByTarget.get(targetKey(entry)) ?? [], tokenEmail)
finalizeApprovalTarget(
context,
entry,
hitsByTarget.get(targetKey(entry)) ?? [],
recentByTarget.get(targetKey(entry)) ?? []
)
)
)
);
@@ -510,6 +528,98 @@ async function searchAcrossApprovalLists(
}
}
/**
* REST-довесок по свежести для очереди согласования: строки маршрута, изменённые
* за последние RECENT_MS, — то, что поисковый индекс мог ещё не подхватить.
*
* Архивные партиции опрашиваются ТОЖЕ, и это не перестраховка: заявка, уехавшая
* в архив, продолжает висеть на согласовании, а решение по ней меняет строки
* маршрута ровно в её, архивной, партиции (см. approvalList.service.ts/setActive).
* Пропустить здесь архив — значит, что дошедшую до согласующего архивную заявку
* он увидит не сразу, а только когда её подхватит индекс; именно эту заявку
* фича «архивные заявки тоже согласуются» и должна показывать вовремя.
*
* Цена вопроса снята батчем, а не отказом от архива: все партиции одного сайта
* уходят ОДНИМ HTTP-запросом (batch API SharePoint — тот же приём, что в
* approvalList.service.ts/createSteps), поэтому число партиций больше не
* добавляет round-trip'ов. Раньше это был отдельный запрос на партицию, да ещё и
* с отдельным ensureUser перед каждым.
*/
async function fetchRecentApprovalRows(
context: WebPartContext,
entries: IApprovalSearchTarget[],
tokenEmail: string,
statuses: string[]
): Promise<Map<string, Array<Record<string, unknown>>>> {
const result = new Map<string, Array<Record<string, unknown>>>();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
// Статус — прямо в $filter: REST-довесок сразу отдаёт только нужные строки,
// без постфильтрации в JS. Кириллица в значении этому не мешает — проверено
// вручную 2026-08-17, см. комментарий у MP_REQUEST_STATUS.
const statusFilter =
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
// Группируем по САЙТУ: батч — это один запрос к одному сайту, и числовой Id
// пользователя тоже свой на каждом сайте.
const bySite = new Map<string, IApprovalSearchTarget[]>();
entries.forEach((e) => {
const key = e.target.siteUrl.toLowerCase();
const bucket = bySite.get(key) ?? [];
bucket.push(e);
bySite.set(key, bucket);
});
await Promise.all(
Array.from(bySite.values()).map(async (siteEntries) => {
const siteUrl = siteEntries[0].target.siteUrl;
try {
const sp = spForSite(siteUrl, context);
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
// POST, а не дешёвый GET.
//
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
// проекте) и фильтруем по TansuSiteApproverId.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin));
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
const [batchedSP, execute] = sp.batched();
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
// отказ по одной партиции внутри батча всплыл бы как unhandled rejection
// раньше, чем мы успели бы его перехватить.
const pending = siteEntries.map((entry) =>
batchedSP.web.lists
.getByTitle(entry.approvalListTitle)
.items.filter(filter)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()
.then((rows) => {
result.set(targetKey(entry), rows as Array<Record<string, unknown>>);
})
.catch((e) => {
// Недоступная партиция (архив переименовали/удалили вручную) не
// должна лишать довеска остальные партиции того же сайта.
console.error(
`[Текущие заявки] «${entry.type.name}» (${entry.target.listId}): недавно изменённые строки согласования не прочитаны:`,
(e as Error).message
);
})
);
await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute);
await Promise.all(pending);
} catch (e) {
console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message);
}
})
);
return result;
}
/** Итог по одной партиции — строки плюс то, что нужно вкатить в общую диагностику типа. */
interface IPartitionApprovalResult {
rows: IMyRequest[];
@@ -519,17 +629,21 @@ interface IPartitionApprovalResult {
}
/**
* Достраивает результат одной партиции поверх уже найденных Search'ем Id
* (searchHitIds — пусто, если для неё ничего не нашлось или комбинированный
* Search целиком не сработал).
* Достраивает результат одной партиции поверх уже собранных кандидатов:
* • searchHitIds — Id из комбинированного Search (пусто, если для этой партиции
* ничего не нашлось или Search целиком не сработал);
* • recentRows — готовые строки из REST-довеска по свежести (пусто в режиме
* текстового поиска и когда за последние RECENT_MS ничего не менялось).
*
* Сама сеть здесь только на дочитывание: собственных «поисковых» запросов
* функция больше не делает — оба источника кандидатов собираются один раз на все
* партиции сразу (см. searchAcrossApprovalLists, fetchRecentApprovalRows).
*/
async function finalizeApprovalTarget(
context: WebPartContext,
entry: IApprovalSearchTarget,
scope: ApprovalScope,
statuses: string[],
searchHitIds: number[],
tokenEmail: string
recentRows: Array<Record<string, unknown>>
): Promise<IPartitionApprovalResult> {
const { type, target, approvalList, approvalListTitle } = entry;
const debug: IPartitionApprovalResult = { rows: [], approvalListTitle };
@@ -558,37 +672,11 @@ async function finalizeApprovalTarget(
)
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
let stepRows: Array<Record<string, unknown>>;
if (scope === 'myTurn') {
// 'myTurn' — гибрид для масштаба (список согласования может быть очень
// большим): Search находит кандидатов, не сканируя список целиком, плюс
// REST по «последним N минутам» (Modified) добирает то, что Search мог ещё
// не проиндексировать.
//
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде
// в проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
const approver = await sp.web.ensureUser(claimsLogin);
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const statusFilter =
statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : '';
const recentRows = (await traced(`recentRowsRest:${type.name}#${target.listId}`, () =>
approvalList.items
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`)
.select('Id', 'TicketId', 'ApprovalStatus')
.top(2000)()
)) as Array<Record<string, unknown>>;
const seenIds = new Set(recentRows.map((r) => r.Id));
stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
} else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
// свежесть, а полнота по всему архиву, поэтому только Search, без
// REST-подстраховки на «последние N минут».
stepRows = searchRows;
}
// Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только
// то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее,
// чем в индексе, а решение «моя очередь сейчас» строится именно на статусе.
const seenIds = new Set(recentRows.map((r) => r.Id));
const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
debug.approvalRowCount = stepRows.length;