рест и серч одновременно
Tests / frontend-test (pull_request) Successful in 1m18s

This commit is contained in:
gorbenko.pavel
2026-08-21 15:00:33 +05:00
parent 7f08a819a9
commit a19444eb28
2 changed files with 117 additions and 51 deletions
@@ -353,18 +353,26 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
const fetchRows = async (): Promise<IPage> => { // onPartial — Search и REST-довесок по свежести («Мои заявки»/«Доработка»,
// см. getOwnRequests/getOwnRequestsByStatus в myRequests.service.ts) теперь
// идут параллельно, и заранее не угадать, какой из двух ответит первым —
// сеть шумит то в одну, то в другую сторону. onPartial зовётся с тем, что
// пришло раньше, чтобы не держать список пустым ради заведомо более
// медленного источника. Остальные ветки (approvals/execution/поиск) его
// просто не принимают — там нет пары «Search + довесок» с независимо
// валидным промежуточным результатом.
const fetchRows = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
// «Доработка» — тот же список, что «Инициированные», просто со статусом, // «Доработка» — тот же список, что «Инициированные», просто со статусом,
// зашитым в раздел, а не выбранным в фильтре. // зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || (view === 'own:created' && statusFilter)) { if (view === 'rework' || (view === 'own:created' && statusFilter)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); return getOwnRequestsByStatus(context, types, status, page, debouncedQuery, onPartial);
} }
if (view === 'own:created') { if (view === 'own:created') {
return q.length > 0 return q.length > 0
? searchOwnRequests(context, types, debouncedQuery, page) ? searchOwnRequests(context, types, debouncedQuery, page)
: getOwnRequests(context, types, page); : getOwnRequests(context, types, page, onPartial);
} }
const queue = QUEUE_BY_VIEW[view]; const queue = QUEUE_BY_VIEW[view];
@@ -387,9 +395,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и // Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
// «Отклонённые мной» приходят разными путями (по автору и по строкам // «Отклонённые мной» приходят разными путями (по автору и по строкам
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только // маршрута соответственно), а колонка у них одинаковая по смыслу. И только
// для видимой страницы — как и автор в hydratePendingAuthors. // для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
const fetchPage = async (): Promise<IPage> => { // показ (onPartial) комментарий ещё не несёт — он дочитывается только для
const result = await fetchRows(); // ИТОГОВОГО результата, полный показ его донесёт следом.
const fetchPage = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
const result = await fetchRows(onPartial);
const decisionStatus = decisionStatusFor(view, statusFilter); const decisionStatus = decisionStatusFor(view, statusFilter);
if (decisionStatus) { if (decisionStatus) {
await hydrateDecisionComments(context, types, result.rows, decisionStatus); await hydrateDecisionComments(context, types, result.rows, decisionStatus);
@@ -399,7 +409,23 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const load = async (forceRender = false): Promise<void> => { const load = async (forceRender = false): Promise<void> => {
try { try {
const fresh = await perfScope(`таблица ${view} стр.${page}`, fetchPage); // Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
// ничего не закешировано (первый заход, экран пуст). Проверяем на
// каждый вызов load(), а не один раз на весь эффект: тот же load()
// переиспользуется автообновлением по таймеру и событием
// rp-requests-changed все 10 минут жизни вкладки, и ко второму вызову
// кеш почти наверняка уже есть — без повторной проверки список бы на
// мгновение "усыхал" при КАЖДОМ фоновом обновлении, а не только при
// самой первой загрузке.
const onPartial = readPage(cacheKey)
? undefined
: (partial: IPage): void => {
if (cancelled) return;
setRows(partial.rows);
setTotalRows(partial.totalRows);
};
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial));
if (cancelled) return; if (cancelled) return;
// Перерисовываем, только если данные реально изменились — иначе при // Перерисовываем, только если данные реально изменились — иначе при
@@ -1252,37 +1252,85 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
} }
} }
// Итог слияния Search-страницы с REST-довеском по свежести — общая логика для
// getOwnRequests и ownRequestsByStatus. Довесок ДОПОЛНЯЕТ Search, а не заменяет:
// то, что Search уже нашёл, в приоритете (там могло появиться более точное
// совпадение по совместному запросу — статус/текст), довесок добавляет только
// то, чего в найденном ещё нет.
function mergeSearchAndFresh(searchPage: IOwnRequestsPage, freshRows: IMyRequest[]): IOwnRequestsPage {
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
const merged = onlyFresh
.concat(searchPage.rows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length };
}
/**
* Гонит Search-страницу и REST-довесок по свежести ПАРАЛЛЕЛЬНО — то один
* отвечает быстрее, то другой, заранее не угадать, и ждать заведомо более
* медленный, держа экран пустым, незачем.
*
* Если задан onPartial — зовёт его РАЗ, как только придёт ПЕРВЫЙ из двух
* источников (та половина, что уже есть, без дедупа со вторым — его мы ещё не
* видели). Возвращает функция в любом случае ИТОГОВЫЙ слитый результат;
* onPartial — только для промежуточного показа, на итог не влияет.
*
* Без onPartial ведёт себя как раньше: ждёт оба источника и сразу отдаёт слитый
* результат — второй показ (полный) экономить незачем, если промежуточный
* всё равно никто не увидит.
*/
async function raceThenMerge(
searchPromise: Promise<IOwnRequestsPage>,
freshPromise: Promise<IMyRequest[]>,
onPartial: ((page: IOwnRequestsPage) => void) | undefined
): Promise<IOwnRequestsPage> {
if (onPartial) {
let searchDone = false;
let freshDone = false;
// Ошибку здесь не обрабатываем — её поймает Promise.all ниже; этот
// отдельный .then только подсматривает за тем, что пришло раньше.
searchPromise.then((r) => {
searchDone = true;
if (!freshDone) onPartial(r);
}, () => undefined);
freshPromise.then((r) => {
freshDone = true;
if (!searchDone) onPartial({ rows: r, totalRows: r.length });
}, () => undefined);
}
const [searchPage, freshRows] = await Promise.all([searchPromise, freshPromise]);
return mergeSearchAndFresh(searchPage, freshRows);
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией // «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по // (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать // «последним N минутам» добирает то, что Search мог не успеть проиндексировать
// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по // (см. RECENT_MS). Довесок применяется только на первой странице — свежее по
// дате там и оказалось бы, будь оно проиндексировано. // дате там и оказалось бы, будь оно проиндексировано.
//
// Search и довесок идут ПАРАЛЛЕЛЬНО (см. raceThenMerge) — раньше довесок
// стартовал только ПОСЛЕ ответа Search, хотя они независимы друг от друга и
// читают разные источники; тем самым просто складывалось время обоих вместо
// времени более медленного из двух.
export async function getOwnRequests( export async function getOwnRequests(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
page: number page: number,
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> { ): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 }; if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page); const searchPromise = searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPromise;
if (page !== 1) return searchPage; const freshPromise = Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))).then((r) =>
r.reduce((all, x) => all.concat(x), [] as IMyRequest[])
);
const freshRows = ( return raceThenMerge(searchPromise, freshPromise, onPartial);
await Promise.all(
types.map((t) => getFreshOwnRowsForType(context, t))
)
).reduce((all, r) => all.concat(r), [] as IMyRequest[]);
const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
const merged = onlyFresh
.concat(searchPage.rows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length };
} }
// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть: // «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть:
@@ -1339,9 +1387,10 @@ export async function getOwnRequestsByStatus(
types: IRequestType[], types: IRequestType[],
status: string, status: string,
page: number, page: number,
query: string = '' query: string = '',
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> { ): Promise<IOwnRequestsPage> {
return ownRequestsByStatus(context, types, status, page, query); return ownRequestsByStatus(context, types, status, page, query, onPartial);
} }
/** /**
@@ -1370,7 +1419,8 @@ async function ownRequestsByStatus(
types: IRequestType[], types: IRequestType[],
status: string, status: string,
page: number, page: number,
query: string query: string,
onPartial?: (page: IOwnRequestsPage) => void
): Promise<IOwnRequestsPage> { ): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 }; if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -1385,7 +1435,7 @@ async function ownRequestsByStatus(
if (text) clauses.push(text); if (text) clauses.push(text);
const kql = clauses.join(' AND '); const kql = clauses.join(' AND ');
const results = await traced('Search: свои заявки', () => const searchPromise: Promise<IOwnRequestsPage> = traced('Search: свои заявки', () =>
sp.search({ sp.search({
Querytext: kql, Querytext: kql,
RowLimit: PAGE_SIZE, RowLimit: PAGE_SIZE,
@@ -1395,34 +1445,24 @@ async function ownRequestsByStatus(
// Свежие сверху — как в searchOwnRequests. // Свежие сверху — как в searchOwnRequests.
SortList: [{ Property: 'Created', Direction: 1 }], SortList: [{ Property: 'Created', Direction: 1 }],
}) })
); ).then((results) => ({
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
const totalRows = results.TotalRows || 0; totalRows: results.TotalRows || 0,
const entries = hitsToEntries(results, types); }));
const searchRows = entries.map(toMyRequestFromSearch);
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
// Довесок по недавним изменениям — только на первой странице и только без // Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (тот же принцип, что в getOwnRequests): заявку могли // текстового поиска (тот же принцип, что в getOwnRequests): заявку могли
// вернуть на доработку только что, индекс мог не успеть её подхватить. // вернуть на доработку только что, индекс мог не успеть её подхватить.
const freshRows = ( // Запускаем СРАЗУ, не дожидаясь Search: условие включения известно из
await Promise.all( // параметров вызова, ждать ради него ответ Search незачем — раньше довесок
types.map((t) => getRecentlyChangedOwnRowsForType(context, t)) // стартовал только ПОСЛЕ Search, хотя независим от его результата.
) if (page !== 1 || query.trim()) return searchPromise;
)
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id))); const freshPromise = Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))).then((r) =>
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); r.reduce((acc, x) => acc.concat(x), [] as IMyRequest[]).filter((x) => x.status === status)
);
const merged = onlyFresh return raceThenMerge(searchPromise, freshPromise, onPartial);
.concat(searchRows)
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, PAGE_SIZE);
return { rows: merged, totalRows: totalRows + onlyFresh.length };
} }
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: