diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index b241bac..1a05bc7 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,3 +1,4 @@ +import { SPFI } from '@pnp/sp'; import { spForSite } from '../../shared/pnp/pnpjs'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; @@ -24,11 +25,24 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). const RECENT_MS = 10 * 60 * 1000; -// Сколько строк согласования максимум забираем из Search за один запрос -// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор -// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении -// лимита пишем предупреждение в лог, а не молча обрезаем. -const SEARCH_ROW_LIMIT = 500; +// Сколько строк максимум запрашиваем у Search за ОДИН HTTP-вызов — верхняя +// граница RowLimit самого SharePoint Search REST API. Полный набор всегда +// собирается несколькими такими вызовами через StartRow (см. +// fetchAllSearchRows) — это не потолок итогового результата. +const SEARCH_PAGE_SIZE = 500; + +// Абсолютный потолок на суммарное число строк, которые fetchAllSearchRows +// заберёт за все страницы, — защита от зависания на аномально большом +// индексе, а не ожидаемый рабочий предел. При достижении пишем предупреждение +// и возвращаем то, что успели собрать, вместо бесконечного перебора. +const SEARCH_HARD_CAP = 5000; + +// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`) +// при дочитывании полей по найденным через Search элементам (см. loadByIds). +// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют +// упереться в лимит, поэтому набор Id одного типа режется на такие пачки, +// а не уходит одним запросом целиком. +const REST_ID_FILTER_CHUNK = 100; // Сколько последних заявок автора дочитываем через REST в довесок к Search // в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО @@ -421,29 +435,24 @@ async function getInvolvedRowsViaSearch( } const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: SEARCH_ROW_LIMIT, - SelectProperties: ['ListItemID'], - TrimDuplicates: false, - }); - - const total = results.TotalRows || 0; - const ids = (results.PrimarySearchResults as Array>) + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`); + const ids = hits .map((r) => Number(r.ListItemID)) .filter((id) => !isNaN(id) && id > 0); - if (total > SEARCH_ROW_LIMIT) { - console.warn( - `[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку` - ); - } if (ids.length === 0) return []; - return (await approvalList.items - .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(ids.length)()) as Array>; + // Id режем на пачки — тот же предел длины URL REST $filter, что и в + // loadByIds (см. REST_ID_FILTER_CHUNK). + const chunks = await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) => + approvalList.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(idsChunk.length)() + ) + ); + return (chunks as Array>>).reduce((all, r) => all.concat(r), []); } catch (e) { console.error( `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, @@ -518,10 +527,64 @@ export interface IOwnRequestsPage { } const PAGE_SIZE = 20; -// Сколько заявок максимум перечисляем в KQL при поиске среди уже отобранных -// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. +// Сколько заявок-кандидатов максимум перечисляем в ОДНОМ KQL-запросе при +// поиске среди уже отобранных (см. filterPendingByQuery) — не потолок +// итогового результата, а размер чанка: кандидатов режем на такие пачки и +// прогоняем каждую отдельным запросом, чтобы строка запроса не разрослась +// до неподъёмной, но при этом ни один кандидат не терялся. const KQL_ID_LIMIT = 300; +function chunk(items: T[], size: number): T[][] { + const result: T[][] = []; + for (let i = 0; i < items.length; i += size) { + result.push(items.slice(i, i + size)); + } + return result; +} + +// Собрать ВЕСЬ результат Search по запросу, а не одну страницу: сам +// SharePoint Search REST API ограничивает RowLimit одного вызова (см. +// SEARCH_PAGE_SIZE), поэтому при большом совпадающем наборе перебираем +// StartRow, пока индекс не перестанет отдавать новые строки или пока не +// упрёмся в SEARCH_HARD_CAP (аварийный потолок, а не ожидаемый лимит — при +// его достижении пишем предупреждение и отдаём то, что успели собрать). +async function fetchAllSearchRows( + sp: SPFI, + kql: string, + selectProperties: string[], + label: string, + sortList?: Array<{ Property: string; Direction: number }> +): Promise>> { + const rows: Array> = []; + let startRow = 0; + + for (;;) { + const results = await sp.search({ + Querytext: kql, + RowLimit: SEARCH_PAGE_SIZE, + StartRow: startRow, + SelectProperties: selectProperties, + TrimDuplicates: false, + ...(sortList ? { SortList: sortList } : {}), + }); + + const page = results.PrimarySearchResults as Array>; + rows.push(...page); + const total = results.TotalRows || 0; + startRow += page.length; + + if (page.length === 0 || startRow >= total) break; + if (rows.length >= SEARCH_HARD_CAP) { + console.warn( + `[Текущие заявки] ${label}: индекс отдал ${total} строк — забрали только первые ${rows.length} (аварийный потолок)` + ); + break; + } + } + + return rows; +} + function siteOrigin(siteUrl: string): string { const match = /^https?:\/\/[^/]+/.exec(siteUrl); return match ? match[0] : siteUrl; @@ -570,11 +633,13 @@ function freeTextClause(query: string): string | undefined { // // Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах // стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии — -// стандартная дедупликация Search иногда схлопывает и их), а без неё индекс -// может вернуть один и тот же элемент несколькими хитами (переиндексация, -// несколько «версий» в crawl). Раньше хиты превращались в строки один-в-один, -// и дубликат из индекса становился дубликатом строки в таблице — с одинаковым -// React-ключом, то есть видимым «залипанием» одной и той же заявки дважды. +// стандартная дедупликация Search схлопывает по содержимому, а не по Id, и +// вместе с ними может случайно схлопнуть две по-настоящему разные, но похожие +// заявки), а без неё индекс может вернуть один и тот же элемент несколькими +// хитами (переиндексация, несколько «версий» в crawl). Раньше хиты +// превращались в строки один-в-один, и дубликат из индекса становился +// дубликатом строки в таблице — с одинаковым React-ключом, то есть видимым +// «залипанием» одной и той же заявки дважды. function hitsToEntries( results: { PrimarySearchResults: unknown[] }, types: IRequestType[] @@ -600,6 +665,11 @@ function hitsToEntries( // Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для // кастомных полей нет гарантии, что они проиндексированы как managed property). +// +// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search +// собирает результат целиком (см. fetchAllSearchRows), их может набраться +// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в +// предел длины URL SharePoint REST. async function loadByIds( context: WebPartContext, entries: Array<{ id: number; type: IRequestType }>, @@ -616,16 +686,21 @@ async function loadByIds( await Promise.all( Array.from(idsByType.entries()).map(async ([type, ids]) => { const sp = spForSite(type.siteUrl as string, context); - const rows = (await sp.web - .getList(type.listId as string) - .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') - .expand('Author') - .top(ids.length)()) as Array>; - rows.forEach((r) => { - const author = r.Author as { EMail?: string; Title?: string } | undefined; - byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); - }); + const list = sp.web.getList(type.listId as string); + + await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => { + const rows = (await list.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(idsChunk.length)()) as Array>; + rows.forEach((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); + }); + }) + ); }) ); return byKey; @@ -929,6 +1004,11 @@ export async function hydrateDecisionComments( // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search // (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по // всем полям), без клиентского REST-матчинга по свежести. +// +// Кандидатов режем на чанки по KQL_ID_LIMIT и гоняем каждый отдельным +// запросом (см. chunk/fetchAllSearchRows) — раньше «лишние» сверх лимита +// кандидаты молча выпадали из поиска, теперь ни один не теряется, просто +// уходит несколькими запросами вместо одного. export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -938,34 +1018,35 @@ export async function filterPendingByQuery( const text = freeTextClause(query); if (!text || rows.length === 0) return rows; - if (rows.length > KQL_ID_LIMIT) { - console.warn( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` - ); - } - const candidates = rows.slice(0, KQL_ID_LIMIT); - - const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); + const scopeTypes = types.filter((t) => rows.some((r) => r.requestTypeId === t.id)); if (scopeTypes.length === 0) return []; const sp = spForSite(scopeTypes[0].siteUrl as string, context); - const kql = [ - `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, - `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, - text, - ].join(' AND '); + const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`; - const results = await sp.search({ - Querytext: kql, - RowLimit: KQL_ID_LIMIT, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - }); + const found = new Set(); + await Promise.all( + chunk(rows, KQL_ID_LIMIT).map(async (candidates) => { + const kql = [ + pathClause, + `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, + text, + ].join(' AND '); + const hits = await fetchAllSearchRows( + sp, + kql, + ['ListItemID', 'Title', 'Path'], + 'Поиск среди ждущих решения' + ); + // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. + hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) => + found.add(`${e.type.id}-${e.id}`) + ); + }) + ); - // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. - const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); - return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); + return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); } // --------------------------------------------------------------------------- @@ -985,8 +1066,10 @@ export interface ISearchPage { totalRows: number; } -// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до -// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. +// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез +// (см. fetchAllSearchRows — весь, а не первые KQL_ID_LIMIT: тот лимит здесь +// больше не действует, это чанк-размер для другой функции, filterPendingByQuery). +// Только Search, без REST-подстраховки на свежесть. export async function searchOwnAll( context: WebPartContext, types: IRequestType[], @@ -1004,12 +1087,8 @@ export async function searchOwnAll( if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: KQL_ID_LIMIT, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - }); + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки'); + const results = { PrimarySearchResults: hits, TotalRows: hits.length }; traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); diff --git a/src/webparts/requestPortal/features/myRequests/searchTrace.ts b/src/webparts/requestPortal/features/myRequests/searchTrace.ts index f2a0614..3759fcf 100644 --- a/src/webparts/requestPortal/features/myRequests/searchTrace.ts +++ b/src/webparts/requestPortal/features/myRequests/searchTrace.ts @@ -136,8 +136,8 @@ export function traceSearchHits( } /** - * Шаг 5: сколько строк этого типа дожило до выдачи после дедупа, проверки - * существования и дочитывания полей через REST. + * Шаг 5: сколько строк этого типа дожило до выдачи после сопоставления с + * адресом списка и дочитывания полей через REST. * * Расхождение «хиты были, строк нет» означает, что элементы из индекса в * списке уже не существуют (удалены, индекс отстал) либо REST их не отдал. @@ -155,7 +155,7 @@ export function traceResultRows( const mineEntries = entries.filter((e) => e.type.id === t.id).length; const mineRows = rows.filter((r) => r.requestTypeId === t.id).length; console.log( - `${PREFIX} ${label}: «${t.name}» — после дедупа/проверки ${mineEntries}, в выдаче ${mineRows}` + `${PREFIX} ${label}: «${t.name}» — сопоставлено со списком ${mineEntries}, в выдаче ${mineRows}` ); if (mineEntries > 0 && mineRows === 0) { console.warn(