оптимизация довеска
This commit is contained in:
@@ -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>>;
|
||||
// Довесок по свежести (если он был) идёт ПЕРВЫМ, а из Search берётся только
|
||||
// то, чего в нём ещё нет: у одной и той же строки статус в довеске свежее,
|
||||
// чем в индексе, а решение «моя очередь сейчас» строится именно на статусе.
|
||||
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;
|
||||
}
|
||||
const stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||
|
||||
debug.approvalRowCount = stepRows.length;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user