отказ от лишнего реста
This commit is contained in:
@@ -49,10 +49,10 @@ const SEARCH_PAGE_SIZE = 500;
|
|||||||
const SEARCH_HARD_CAP = 5000;
|
const SEARCH_HARD_CAP = 5000;
|
||||||
|
|
||||||
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
|
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
|
||||||
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
|
// при дочитывании строк маршрута/заявки по найденным через Search Id (см.
|
||||||
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
|
// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL —
|
||||||
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
|
// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного
|
||||||
// а не уходит одним запросом целиком.
|
// типа режется на такие пачки, а не уходит одним запросом целиком.
|
||||||
const REST_ID_FILTER_CHUNK = 100;
|
const REST_ID_FILTER_CHUNK = 100;
|
||||||
|
|
||||||
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
// Сколько последних заявок автора дочитываем через REST в довесок к Search
|
||||||
@@ -83,6 +83,42 @@ const MP_STATUS = 'ApprovalStatusOWSCHCS';
|
|||||||
// SharePoint — сама схема поиска исправна.
|
// SharePoint — сама схема поиска исправна.
|
||||||
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
|
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
|
||||||
|
|
||||||
|
// Автор заявки (Person-поле Author списка ДАННЫХ) — суффикс OWSUSER, без
|
||||||
|
// префикса группы колонок (поле стандартное, не сайтовое, как и RequestStatus
|
||||||
|
// выше). Значение — «<логин> | <ФИО>» (см. parseAuthorOwsUser). Уже
|
||||||
|
// использовался в KQL (AuthorOWSUSER:"...") для отбора «своих» заявок; здесь
|
||||||
|
// то же свойство дочитывается из ответа Search вместо REST.
|
||||||
|
const MP_AUTHOR = 'AuthorOWSUSER';
|
||||||
|
|
||||||
|
// Уникальный код заявки (колонка RequestUniqueCode) — суффикс OWSTEXT, тип
|
||||||
|
// исходного поля Text.
|
||||||
|
const MP_CODE = 'RequestUniqueCodeOWSTEXT';
|
||||||
|
|
||||||
|
// Поля заявки, отдаваемые ПРЯМО индексом, без REST-дочитывания после Search —
|
||||||
|
// подтверждено вручную (tools/sp props, 2026-08-20): Queryable ≠ Retrievable у
|
||||||
|
// managed property, поэтому то, что раньше проверялось только на фильтрацию
|
||||||
|
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
|
||||||
|
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
|
||||||
|
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
|
||||||
|
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) отдаёт
|
||||||
|
// готовое отображаемое имя — короче и надёжнее выковыривать его из
|
||||||
|
// MP_AUTHOR/parseAuthorOwsUser, там он нужен только ради почты.
|
||||||
|
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
|
||||||
|
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
|
||||||
|
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
|
||||||
|
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
|
||||||
|
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
|
||||||
|
*/
|
||||||
|
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
|
||||||
|
const text = typeof value === 'string' ? value : '';
|
||||||
|
const sep = text.indexOf(' | ');
|
||||||
|
if (sep < 0) return { email: text.trim(), name: '' };
|
||||||
|
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
|
||||||
|
}
|
||||||
|
|
||||||
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
|
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
|
||||||
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
|
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
|
||||||
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
|
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
|
||||||
@@ -687,8 +723,7 @@ async function finalizeApprovalTarget(
|
|||||||
? []
|
? []
|
||||||
: (
|
: (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
// Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK).
|
||||||
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
|
||||||
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
||||||
approvalList.items
|
approvalList.items
|
||||||
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
@@ -762,11 +797,12 @@ async function finalizeApprovalTarget(
|
|||||||
// по переданным строкам. Мутирует переданные объекты (проставляет
|
// по переданным строкам. Мутирует переданные объекты (проставляет
|
||||||
// authorEmail/authorName).
|
// authorEmail/authorName).
|
||||||
//
|
//
|
||||||
// Строки, у которых автор УЖЕ известен, пропускаются: и 'own' (loadByIds), и
|
// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо
|
||||||
// 'approval' (finalizeApprovalTarget) теперь получают его тем же запросом, что
|
// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget)
|
||||||
// и остальные поля, поэтому на обычных путях сюда доходить нечему и запрос не
|
// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда
|
||||||
// уходит вовсе. Функция оставлена страховкой на случай источника строк без
|
// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на
|
||||||
// автора — молча показывать пустого инициатора хуже, чем один лишний запрос.
|
// случай источника строк без автора — молча показывать пустого инициатора
|
||||||
|
// хуже, чем один лишний запрос.
|
||||||
async function hydrateAuthors(
|
async function hydrateAuthors(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
@@ -1058,7 +1094,7 @@ function hitsToEntries(
|
|||||||
): ISearchEntry[] {
|
): ISearchEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
||||||
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
|
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || ''), raw: r }))
|
||||||
.filter((h) => !isNaN(h.id) && h.id > 0)
|
.filter((h) => !isNaN(h.id) && h.id > 0)
|
||||||
.map((h) => {
|
.map((h) => {
|
||||||
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
|
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
|
||||||
@@ -1068,7 +1104,7 @@ function hitsToEntries(
|
|||||||
// из того же списка, где его нашёл Search (см. targetForPath).
|
// из того же списка, где его нашёл Search (см. targetForPath).
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
const target = targetForPath(h.path, type);
|
const target = targetForPath(h.path, type);
|
||||||
if (target) return { id: h.id, type, target };
|
if (target) return { id: h.id, type, target, raw: h.raw };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
@@ -1081,72 +1117,50 @@ function hitsToEntries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
|
/**
|
||||||
|
* Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка
|
||||||
|
* ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES
|
||||||
|
* и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно.
|
||||||
|
*/
|
||||||
interface ISearchEntry {
|
interface ISearchEntry {
|
||||||
id: number;
|
id: number;
|
||||||
type: IRequestType;
|
type: IRequestType;
|
||||||
target: IListTarget;
|
target: IListTarget;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
/**
|
||||||
// кастомных полей нет гарантии, что они проиндексированы как managed property).
|
* Строка «Моих заявок» ПРЯМО из ответа Search — без второго прохода по REST.
|
||||||
//
|
*
|
||||||
// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search
|
* Раньше здесь был REST-запрос по найденным Id (loadByIds): считалось, что
|
||||||
// собирает результат целиком (см. fetchAllSearchRows), их может набраться
|
* поисковый индекс отдаёт не все колонки. Проверено вручную (tools/sp props,
|
||||||
// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в
|
* 2026-08-20) — отдаёт все нужные, см. OWN_SELECT_PROPERTIES. Оставался вопрос
|
||||||
// предел длины URL SharePoint REST.
|
* СВЕЖЕСТИ: значения теперь из индекса, который переиндексируется с задержкой,
|
||||||
async function loadByIds(
|
* а не «только что созданный элемент» напрямую. Для 'own' это уже было принятым
|
||||||
context: WebPartContext,
|
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
|
||||||
entries: ISearchEntry[],
|
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
|
||||||
kind: 'own' | 'approval'
|
*/
|
||||||
): Promise<Map<string, IMyRequest>> {
|
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
|
||||||
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
|
const r = entry.raw;
|
||||||
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
|
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
|
||||||
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
|
return {
|
||||||
entries.forEach((e) => {
|
id: entry.id,
|
||||||
const key = e.target.listId.toLowerCase();
|
code: (r[MP_CODE] as string) || '',
|
||||||
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
|
title: (r.Title as string) || '',
|
||||||
bucket.ids.push(e.id);
|
status: (r[MP_REQUEST_STATUS] as string) || '',
|
||||||
byPartition.set(key, bucket);
|
authorEmail: author.email,
|
||||||
});
|
// 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES);
|
||||||
|
// из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст.
|
||||||
const byKey = new Map<string, IMyRequest>();
|
authorName: ((r.Author as string) || author.name || '').trim(),
|
||||||
await Promise.all(
|
created: (r.Created as string) || '',
|
||||||
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
|
requestTypeId: entry.type.id,
|
||||||
// Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть
|
requestTypeName: entry.type.name,
|
||||||
// недоступен (удалён, переименован, остался в реестре после смены списка у
|
section: entry.type.section,
|
||||||
// типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции
|
formKey: entry.type.pageUrl,
|
||||||
// один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не
|
kind: 'own',
|
||||||
// увидел бы ничего вместо «всё, кроме одного архива».
|
listId: entry.target.listId,
|
||||||
try {
|
siteUrl: entry.target.siteUrl,
|
||||||
const sp = spForSite(target.siteUrl, context);
|
};
|
||||||
const list = sp.web.getList(target.listId);
|
|
||||||
|
|
||||||
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', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
|
||||||
.expand('Author')
|
|
||||||
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
|
||||||
rows.forEach((r) => {
|
|
||||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
|
||||||
byKey.set(
|
|
||||||
requestKey(target.listId, r.Id as number),
|
|
||||||
toMyRequest(r, author, type, kind, target)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`,
|
|
||||||
(e as Error).message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return byKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
|
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
|
||||||
@@ -1287,7 +1301,7 @@ export async function searchOwnRequests(
|
|||||||
|
|
||||||
const clauses = [
|
const clauses = [
|
||||||
pathClauseForTypes(types),
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||||
];
|
];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
@@ -1298,7 +1312,7 @@ export async function searchOwnRequests(
|
|||||||
Querytext: kql,
|
Querytext: kql,
|
||||||
RowLimit: PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
SelectProperties: OWN_SELECT_PROPERTIES,
|
||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
@@ -1306,13 +1320,9 @@ export async function searchOwnRequests(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const totalRows = results.TotalRows || 0;
|
const totalRows = results.TotalRows || 0;
|
||||||
const entries = hitsToEntries(results, types);
|
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
|
||||||
const byKey =
|
// здесь больше нет. Порядок задаёт релевантность поиска.
|
||||||
entries.length > 0
|
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
|
||||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
|
||||||
: new Map<string, IMyRequest>();
|
|
||||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
|
||||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
|
||||||
|
|
||||||
return { rows, totalRows };
|
return { rows, totalRows };
|
||||||
}
|
}
|
||||||
@@ -1331,16 +1341,18 @@ export async function getOwnRequestsByStatus(
|
|||||||
page: number,
|
page: number,
|
||||||
query: string = ''
|
query: string = ''
|
||||||
): Promise<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
return ownRequestsByStatus(context, types, status, page, query, true);
|
return ownRequestsByStatus(context, types, status, page, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню.
|
* Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню.
|
||||||
*
|
*
|
||||||
* Отличие от getOwnRequestsByStatus одно: не дочитываются поля найденных заявок
|
* До 2026-08-20 у этого была отдельная REST-развилка (withRows): дочитывание
|
||||||
* (loadByIds). Счётчику они не нужны — ему нужен totalRows, — а замер 2026-08-20
|
* полей найденных заявок стоило отдельного запроса, и счётчику, которому нужен
|
||||||
* показал, что этот шаг стоит ~200-300 мс и идёт СТРОГО ПОСЛЕ Search, то есть
|
* только totalRows, было жаль на него тратиться. Теперь поля идут прямо из
|
||||||
* целиком лежит на критическом пути открытия портала.
|
* ответа Search (см. toMyRequestFromSearch) — построить rows настолько же
|
||||||
|
* дёшево, насколько и посчитать totalRows, отдельная ветка была бы одной лишь
|
||||||
|
* сложностью без выигрыша.
|
||||||
*
|
*
|
||||||
* Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка
|
* Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка
|
||||||
* по-прежнему не расходятся (ради чего они и считаются одним кодом).
|
* по-прежнему не расходятся (ради чего они и считаются одним кодом).
|
||||||
@@ -1350,7 +1362,7 @@ export async function countOwnRequestsByStatus(
|
|||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return (await ownRequestsByStatus(context, types, status, 1, '', false)).totalRows;
|
return (await ownRequestsByStatus(context, types, status, 1, '')).totalRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ownRequestsByStatus(
|
async function ownRequestsByStatus(
|
||||||
@@ -1358,8 +1370,7 @@ async function ownRequestsByStatus(
|
|||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
status: string,
|
status: string,
|
||||||
page: number,
|
page: number,
|
||||||
query: string,
|
query: string
|
||||||
withRows: boolean
|
|
||||||
): Promise<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||||
|
|
||||||
@@ -1367,7 +1378,7 @@ async function ownRequestsByStatus(
|
|||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
const clauses = [
|
const clauses = [
|
||||||
pathClauseForTypes(types),
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||||
`${MP_REQUEST_STATUS}:"${status}"`,
|
`${MP_REQUEST_STATUS}:"${status}"`,
|
||||||
];
|
];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
@@ -1379,7 +1390,7 @@ async function ownRequestsByStatus(
|
|||||||
Querytext: kql,
|
Querytext: kql,
|
||||||
RowLimit: PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
SelectProperties: OWN_SELECT_PROPERTIES,
|
||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
// Свежие сверху — как в searchOwnRequests.
|
// Свежие сверху — как в searchOwnRequests.
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
@@ -1388,11 +1399,7 @@ async function ownRequestsByStatus(
|
|||||||
|
|
||||||
const totalRows = results.TotalRows || 0;
|
const totalRows = results.TotalRows || 0;
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey =
|
const searchRows = entries.map(toMyRequestFromSearch);
|
||||||
withRows && entries.length > 0
|
|
||||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
|
||||||
: new Map<string, IMyRequest>();
|
|
||||||
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
|
||||||
|
|
||||||
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
|
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
|
||||||
|
|
||||||
@@ -1407,9 +1414,6 @@ async function ownRequestsByStatus(
|
|||||||
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||||
.filter((r) => r.status === status);
|
.filter((r) => r.status === status);
|
||||||
|
|
||||||
// Ключи для дедупа берём из ХИТОВ индекса, а не из дочитанных строк: пара
|
|
||||||
// «партиция + Id» известна уже по ответу Search, и режиму счётчика (withRows
|
|
||||||
// = false) не приходится ради этого дочитывать поля.
|
|
||||||
const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id)));
|
const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id)));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||||
|
|
||||||
@@ -1616,24 +1620,19 @@ export async function searchOwnAll(
|
|||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
const clauses = [
|
const clauses = [
|
||||||
pathClauseForTypes(types),
|
pathClauseForTypes(types),
|
||||||
`AuthorOWSUSER:"${claimsLogin}"`,
|
`${MP_AUTHOR}:"${claimsLogin}"`,
|
||||||
];
|
];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
const kql = clauses.join(' AND ');
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки');
|
const hits = await fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Свои заявки');
|
||||||
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
|
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
|
||||||
|
|
||||||
|
|
||||||
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey =
|
const rows = entries.map(toMyRequestFromSearch);
|
||||||
entries.length > 0
|
|
||||||
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
|
||||||
: new Map<string, IMyRequest>();
|
|
||||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
|
||||||
|
|
||||||
traceResultRows('Свои заявки', types, entries, rows);
|
traceResultRows('Свои заявки', types, entries, rows);
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
@@ -67,12 +67,44 @@ interface IStat {
|
|||||||
maxMs: number;
|
maxMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Накопленное по одной операции за ВСЕ её прогоны — база для среднего. */
|
/**
|
||||||
|
* Сэмплы одной метки по всем прогонам — база для МЕДИАНЫ, а не среднего.
|
||||||
|
*
|
||||||
|
* Среднее одним выбросом (случайный 429 с ретраем, холодный кеш на первом
|
||||||
|
* заходе) утаскивает за собой всю картину надолго — а именно выбросы здесь
|
||||||
|
* основной случай, не редкость. Медиана его игнорирует: важнее «как обычно»,
|
||||||
|
* а не «как в сумме с той единственной аномалией».
|
||||||
|
*/
|
||||||
|
interface ILabelSamples {
|
||||||
|
/** totalMs метки за каждый прогон, где она встретилась (метка не обязана быть в каждом). */
|
||||||
|
totalMs: number[];
|
||||||
|
calls: number[];
|
||||||
|
/** Худший случай за всё время — в отличие от totalMs, это не медиана, а running max. */
|
||||||
|
maxMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сколько последних прогонов метки хранить ради медианы. Не безлимитный рост:
|
||||||
|
// в долгой сессии старые сэмплы устаревают быстрее, чем на них смотрят, а лимит
|
||||||
|
// защищает от утечки памяти на вкладке, оставленной открытой на весь день.
|
||||||
|
const MAX_SAMPLES = 50;
|
||||||
|
|
||||||
|
function pushSample(list: number[], value: number): void {
|
||||||
|
list.push(value);
|
||||||
|
if (list.length > MAX_SAMPLES) list.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Накопленное по одной операции за ВСЕ её прогоны — база для медианы. */
|
||||||
interface IRunHistory {
|
interface IRunHistory {
|
||||||
runs: number;
|
wallSamples: number[];
|
||||||
wallTotalMs: number;
|
stages: Map<string, ILabelSamples>;
|
||||||
stages: Map<string, IStat>;
|
requests: Map<string, ILabelSamples>;
|
||||||
requests: Map<string, IStat>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Текущий (ещё не напечатанный) прогон.
|
// Текущий (ещё не напечатанный) прогон.
|
||||||
@@ -142,6 +174,18 @@ function installFlag(): void {
|
|||||||
set: (value: unknown): void => {
|
set: (value: unknown): void => {
|
||||||
enabled = !!value;
|
enabled = !!value;
|
||||||
writeStoredFlag(enabled);
|
writeStoredFlag(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
// Тишина СРАЗУ, а не после того, что уже накопилось. Без этого operация,
|
||||||
|
// стартовавшая ДО выключения (perfScope с сетевым шагом внутри) или
|
||||||
|
// ждущая debounce-таймера (см. scheduleIdleFlush), всё равно печатала
|
||||||
|
// итог ПОСЛЕ выключения — выглядело так, будто window.rpPerf = false
|
||||||
|
// не действует.
|
||||||
|
if (idleTimer !== undefined) {
|
||||||
|
window.clearTimeout(idleTimer);
|
||||||
|
idleTimer = undefined;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`);
|
console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,7 +225,7 @@ function scheduleIdleFlush(): void {
|
|||||||
if (idleTimer !== undefined) window.clearTimeout(idleTimer);
|
if (idleTimer !== undefined) window.clearTimeout(idleTimer);
|
||||||
idleTimer = window.setTimeout(() => {
|
idleTimer = window.setTimeout(() => {
|
||||||
idleTimer = undefined;
|
idleTimer = undefined;
|
||||||
if (openScopes === 0) print();
|
if (openScopes === 0 && perfEnabled()) print();
|
||||||
}, IDLE_FLUSH_MS);
|
}, IDLE_FLUSH_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +267,11 @@ export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise
|
|||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
openScopes -= 1;
|
openScopes -= 1;
|
||||||
if (openScopes === 0) print();
|
// perfEnabled() — на выходе, а не только на входе: операцию могли выключить,
|
||||||
|
// пока она ещё выполнялась (см. setter window.rpPerf выше), и тогда печатать
|
||||||
|
// её итог уже не нужно — accumulated-данные к этому моменту сам setter уже
|
||||||
|
// сбросил.
|
||||||
|
if (openScopes === 0 && perfEnabled()) print();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,19 +355,19 @@ function reset(): void {
|
|||||||
* Ключ, под которым прогоны копятся в историю.
|
* Ключ, под которым прогоны копятся в историю.
|
||||||
*
|
*
|
||||||
* Номер страницы из метки убираем: пролистать вкладку по страницам — самый
|
* Номер страницы из метки убираем: пролистать вкладку по страницам — самый
|
||||||
* удобный способ набрать несколько замеров одной и той же операции, и усреднять
|
* удобный способ набрать несколько замеров одной и той же операции, и считать
|
||||||
* их надо ВМЕСТЕ. Иначе каждая страница завела бы свою историю из одного
|
* медиану надо по ним ВМЕСТЕ. Иначе каждая страница завела бы свою историю из
|
||||||
* прогона, и среднее никогда бы не набралось.
|
* одного прогона, и медиане не по чему было бы считаться.
|
||||||
*/
|
*/
|
||||||
function historyKey(title: string): string {
|
function historyKey(title: string): string {
|
||||||
return title.replace(/\s*стр\.\d+/g, '').trim();
|
return title.replace(/\s*стр\.\d+/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeInto(target: Map<string, IStat>, source: Map<string, IStat>): void {
|
function recordSamples(target: Map<string, ILabelSamples>, source: Map<string, IStat>): void {
|
||||||
source.forEach((s, label) => {
|
source.forEach((s, label) => {
|
||||||
const t = target.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 };
|
const t = target.get(label) ?? { totalMs: [], calls: [], maxMs: 0 };
|
||||||
t.calls += s.calls;
|
pushSample(t.totalMs, s.totalMs);
|
||||||
t.totalMs += s.totalMs;
|
pushSample(t.calls, s.calls);
|
||||||
t.maxMs = Math.max(t.maxMs, s.maxMs);
|
t.maxMs = Math.max(t.maxMs, s.maxMs);
|
||||||
target.set(label, t);
|
target.set(label, t);
|
||||||
});
|
});
|
||||||
@@ -346,8 +394,10 @@ function totalCalls(source: Map<string, IStat>): number {
|
|||||||
* window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть
|
* window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть
|
||||||
* есть на что.
|
* есть на что.
|
||||||
*
|
*
|
||||||
* Среднее в строке важнее текущего значения: одиночный сетевой замер шумит
|
* Медиана в строке важнее текущего значения: одиночный сетевой замер шумит
|
||||||
* настолько, что по нему нельзя сказать, стало лучше или просто повезло.
|
* настолько, что по нему нельзя сказать, стало лучше или просто повезло, а
|
||||||
|
* среднее в этом же шуме утаскивает один выброс (холодный кеш, ретрай на 429)
|
||||||
|
* далеко за пределы того, что происходит обычно.
|
||||||
*/
|
*/
|
||||||
function print(): void {
|
function print(): void {
|
||||||
if (idleTimer !== undefined) {
|
if (idleTimer !== undefined) {
|
||||||
@@ -361,15 +411,13 @@ function print(): void {
|
|||||||
const key = historyKey(title);
|
const key = historyKey(title);
|
||||||
|
|
||||||
const hist: IRunHistory = history.get(key) ?? {
|
const hist: IRunHistory = history.get(key) ?? {
|
||||||
runs: 0,
|
wallSamples: [],
|
||||||
wallTotalMs: 0,
|
stages: new Map<string, ILabelSamples>(),
|
||||||
stages: new Map<string, IStat>(),
|
requests: new Map<string, ILabelSamples>()
|
||||||
requests: new Map<string, IStat>()
|
|
||||||
};
|
};
|
||||||
hist.runs += 1;
|
pushSample(hist.wallSamples, wallMs);
|
||||||
hist.wallTotalMs += wallMs;
|
recordSamples(hist.stages, stages);
|
||||||
mergeInto(hist.stages, stages);
|
recordSamples(hist.requests, requests);
|
||||||
mergeInto(hist.requests, requests);
|
|
||||||
history.set(key, hist);
|
history.set(key, hist);
|
||||||
|
|
||||||
// Дольше всего — по этапам, если разметка есть; иначе по видам запросов
|
// Дольше всего — по этапам, если разметка есть; иначе по видам запросов
|
||||||
@@ -378,7 +426,7 @@ function print(): void {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` +
|
`${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` +
|
||||||
` (сред. ${Math.round(hist.wallTotalMs / hist.runs)} за ${hist.runs})` +
|
` (мед. ${Math.round(median(hist.wallSamples))} за ${hist.wallSamples.length})` +
|
||||||
` | запросов ${totalCalls(requests)}` +
|
` | запросов ${totalCalls(requests)}` +
|
||||||
(top ? ` | дольше всего: ${top}` : '')
|
(top ? ` | дольше всего: ${top}` : '')
|
||||||
);
|
);
|
||||||
@@ -386,18 +434,20 @@ function print(): void {
|
|||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строки полной раскладки по одной операции — средние за все её прогоны. */
|
/** Строки полной раскладки по одной операции — медианы за все её прогоны. */
|
||||||
function detailLines(key: string, hist: IRunHistory): string[] {
|
function detailLines(key: string, hist: IRunHistory): string[] {
|
||||||
const lines = [`${PREFIX} ${key} - прогонов ${hist.runs}, ожидание в среднем ${Math.round(hist.wallTotalMs / hist.runs)} мс`];
|
const lines = [
|
||||||
|
`${PREFIX} ${key} - прогонов ${hist.wallSamples.length}, ожидание медиана ${Math.round(median(hist.wallSamples))} мс`
|
||||||
|
];
|
||||||
|
|
||||||
const dump = (name: string, source: Map<string, IStat>): void => {
|
const dump = (name: string, source: Map<string, ILabelSamples>): void => {
|
||||||
if (source.size === 0) return;
|
if (source.size === 0) return;
|
||||||
lines.push(` ${name} (в среднем за прогон)`);
|
lines.push(` ${name} (медиана за прогон)`);
|
||||||
Array.from(source.entries())
|
Array.from(source.entries())
|
||||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
.sort((a, b) => median(b[1].totalMs) - median(a[1].totalMs))
|
||||||
.forEach(([label, s]) => {
|
.forEach(([label, s]) => {
|
||||||
lines.push(
|
lines.push(
|
||||||
` ${Math.round(s.totalMs / hist.runs)} мс, вызовов ${(s.calls / hist.runs).toFixed(1)}, макс ${Math.round(s.maxMs)} - ${label}`
|
` ${Math.round(median(s.totalMs))} мс, вызовов ${Math.round(median(s.calls))}, макс ${Math.round(s.maxMs)} - ${label}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -425,9 +475,9 @@ function installDetailApi(): void {
|
|||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Старые прогоны тянут среднее назад, и «стало быстрее» проявится в нём
|
// Старые прогоны остаются в окне (см. MAX_SAMPLES) и тянут медиану назад —
|
||||||
// только через несколько заходов — перед сравнением «до/после» серию надо
|
// «стало быстрее» проявится в ней только после того, как их наберётся
|
||||||
// обнулять.
|
// больше половины окна. Перед сравнением «до/после» серию надо обнулять.
|
||||||
w.rpPerfReset = (): void => {
|
w.rpPerfReset = (): void => {
|
||||||
history.clear();
|
history.clear();
|
||||||
reset();
|
reset();
|
||||||
|
|||||||
Reference in New Issue
Block a user