From 7f08a819a9598cfd8d3f2482aab6b65db4565537 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 21 Aug 2026 14:47:03 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BE=D1=82?= =?UTF-8?q?=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B3=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 215 +++++++++--------- .../requestPortal/shared/perfTrace.ts | 118 +++++++--- 2 files changed, 191 insertions(+), 142 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 58d06d3..1c36bcd 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -49,10 +49,10 @@ const SEARCH_PAGE_SIZE = 500; const SEARCH_HARD_CAP = 5000; // Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`) -// при дочитывании полей по найденным через Search элементам (см. loadByIds). -// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют -// упереться в лимит, поэтому набор Id одного типа режется на такие пачки, -// а не уходит одним запросом целиком. +// при дочитывании строк маршрута/заявки по найденным через Search Id (см. +// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL — +// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного +// типа режется на такие пачки, а не уходит одним запросом целиком. const REST_ID_FILTER_CHUNK = 100; // Сколько последних заявок автора дочитываем через REST в довесок к Search @@ -83,6 +83,42 @@ const MP_STATUS = 'ApprovalStatusOWSCHCS'; // SharePoint — сама схема поиска исправна. 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 напрямую: при отладочной подмене личности // (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны // отбираться по подменённому сотруднику — иначе подмена показывала бы чужой @@ -687,8 +723,7 @@ async function finalizeApprovalTarget( ? [] : ( await Promise.all( - // Id режем на пачки — тот же предел длины URL REST $filter, что и в - // loadByIds (см. REST_ID_FILTER_CHUNK). + // Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK). chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) => approvalList.items .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) @@ -762,11 +797,12 @@ async function finalizeApprovalTarget( // по переданным строкам. Мутирует переданные объекты (проставляет // authorEmail/authorName). // -// Строки, у которых автор УЖЕ известен, пропускаются: и 'own' (loadByIds), и -// 'approval' (finalizeApprovalTarget) теперь получают его тем же запросом, что -// и остальные поля, поэтому на обычных путях сюда доходить нечему и запрос не -// уходит вовсе. Функция оставлена страховкой на случай источника строк без -// автора — молча показывать пустого инициатора хуже, чем один лишний запрос. +// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо +// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget) +// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда +// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на +// случай источника строк без автора — молча показывать пустого инициатора +// хуже, чем один лишний запрос. async function hydrateAuthors( context: WebPartContext, types: IRequestType[], @@ -1058,7 +1094,7 @@ function hitsToEntries( ): ISearchEntry[] { const seen = new Set(); return (results.PrimarySearchResults as Array>) - .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) .map((h) => { // Сравнение по границе сегмента (см. listPathPrefix): при «голом» @@ -1068,7 +1104,7 @@ function hitsToEntries( // из того же списка, где его нашёл Search (см. targetForPath). for (const type of types) { 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; }) @@ -1081,72 +1117,50 @@ function hitsToEntries( }); } -/** Найденный хит: Id, его тип и партиция, в которой он лежит. */ +/** + * Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка + * ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES + * и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно. + */ interface ISearchEntry { id: number; type: IRequestType; target: IListTarget; + raw: Record; } -// Дочитать поля заявок по найденным 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: ISearchEntry[], - kind: 'own' | 'approval' -): Promise> { - // Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем - // списке либо не найдётся, либо укажет на чужую заявку с тем же Id. - const byPartition = new Map(); - entries.forEach((e) => { - const key = e.target.listId.toLowerCase(); - const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] }; - bucket.ids.push(e.id); - byPartition.set(key, bucket); - }); - - const byKey = new Map(); - await Promise.all( - Array.from(byPartition.values()).map(async ({ type, target, ids }) => { - // Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть - // недоступен (удалён, переименован, остался в реестре после смены списка у - // типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции - // один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не - // увидел бы ничего вместо «всё, кроме одного архива». - try { - 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>; - 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; +/** + * Строка «Моих заявок» ПРЯМО из ответа Search — без второго прохода по REST. + * + * Раньше здесь был REST-запрос по найденным Id (loadByIds): считалось, что + * поисковый индекс отдаёт не все колонки. Проверено вручную (tools/sp props, + * 2026-08-20) — отдаёт все нужные, см. OWN_SELECT_PROPERTIES. Оставался вопрос + * СВЕЖЕСТИ: значения теперь из индекса, который переиндексируется с задержкой, + * а не «только что созданный элемент» напрямую. Для 'own' это уже было принятым + * компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку + * (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType). + */ +function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest { + const r = entry.raw; + const author = parseAuthorOwsUser(r[MP_AUTHOR]); + return { + id: entry.id, + code: (r[MP_CODE] as string) || '', + title: (r.Title as string) || '', + status: (r[MP_REQUEST_STATUS] as string) || '', + authorEmail: author.email, + // 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES); + // из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст. + authorName: ((r.Author as string) || author.name || '').trim(), + created: (r.Created as string) || '', + requestTypeId: entry.type.id, + requestTypeName: entry.type.name, + section: entry.type.section, + formKey: entry.type.pageUrl, + kind: 'own', + listId: entry.target.listId, + siteUrl: entry.target.siteUrl, + }; } // Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход @@ -1287,7 +1301,7 @@ export async function searchOwnRequests( const clauses = [ pathClauseForTypes(types), - `AuthorOWSUSER:"${claimsLogin}"`, + `${MP_AUTHOR}:"${claimsLogin}"`, ]; const text = freeTextClause(query); if (text) clauses.push(text); @@ -1298,7 +1312,7 @@ export async function searchOwnRequests( Querytext: kql, RowLimit: PAGE_SIZE, StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], + SelectProperties: OWN_SELECT_PROPERTIES, TrimDuplicates: false, // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). SortList: [{ Property: 'Created', Direction: 1 }], @@ -1306,13 +1320,9 @@ export async function searchOwnRequests( ); const totalRows = results.TotalRows || 0; - const entries = hitsToEntries(results, types); - const byKey = - entries.length > 0 - ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) - : new Map(); - // Порядок задаёт релевантность поиска, а не порядок ответов REST. - const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); + // Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания + // здесь больше нет. Порядок задаёт релевантность поиска. + const rows = hitsToEntries(results, types).map(toMyRequestFromSearch); return { rows, totalRows }; } @@ -1331,16 +1341,18 @@ export async function getOwnRequestsByStatus( page: number, query: string = '' ): Promise { - return ownRequestsByStatus(context, types, status, page, query, true); + return ownRequestsByStatus(context, types, status, page, query); } /** * Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню. * - * Отличие от getOwnRequestsByStatus одно: не дочитываются поля найденных заявок - * (loadByIds). Счётчику они не нужны — ему нужен totalRows, — а замер 2026-08-20 - * показал, что этот шаг стоит ~200-300 мс и идёт СТРОГО ПОСЛЕ Search, то есть - * целиком лежит на критическом пути открытия портала. + * До 2026-08-20 у этого была отдельная REST-развилка (withRows): дочитывание + * полей найденных заявок стоило отдельного запроса, и счётчику, которому нужен + * только totalRows, было жаль на него тратиться. Теперь поля идут прямо из + * ответа Search (см. toMyRequestFromSearch) — построить rows настолько же + * дёшево, насколько и посчитать totalRows, отдельная ветка была бы одной лишь + * сложностью без выигрыша. * * Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка * по-прежнему не расходятся (ради чего они и считаются одним кодом). @@ -1350,7 +1362,7 @@ export async function countOwnRequestsByStatus( types: IRequestType[], status: string ): Promise { - return (await ownRequestsByStatus(context, types, status, 1, '', false)).totalRows; + return (await ownRequestsByStatus(context, types, status, 1, '')).totalRows; } async function ownRequestsByStatus( @@ -1358,8 +1370,7 @@ async function ownRequestsByStatus( types: IRequestType[], status: string, page: number, - query: string, - withRows: boolean + query: string ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; @@ -1367,7 +1378,7 @@ async function ownRequestsByStatus( const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ pathClauseForTypes(types), - `AuthorOWSUSER:"${claimsLogin}"`, + `${MP_AUTHOR}:"${claimsLogin}"`, `${MP_REQUEST_STATUS}:"${status}"`, ]; const text = freeTextClause(query); @@ -1379,7 +1390,7 @@ async function ownRequestsByStatus( Querytext: kql, RowLimit: PAGE_SIZE, StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], + SelectProperties: OWN_SELECT_PROPERTIES, TrimDuplicates: false, // Свежие сверху — как в searchOwnRequests. SortList: [{ Property: 'Created', Direction: 1 }], @@ -1388,11 +1399,7 @@ async function ownRequestsByStatus( const totalRows = results.TotalRows || 0; const entries = hitsToEntries(results, types); - const byKey = - withRows && entries.length > 0 - ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) - : new Map(); - const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); + const searchRows = entries.map(toMyRequestFromSearch); if (page !== 1 || query.trim()) return { rows: searchRows, totalRows }; @@ -1407,9 +1414,6 @@ async function ownRequestsByStatus( .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) .filter((r) => r.status === status); - // Ключи для дедупа берём из ХИТОВ индекса, а не из дочитанных строк: пара - // «партиция + Id» известна уже по ответу Search, и режиму счётчика (withRows - // = false) не приходится ради этого дочитывать поля. const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id))); 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 clauses = [ pathClauseForTypes(types), - `AuthorOWSUSER:"${claimsLogin}"`, + `${MP_AUTHOR}:"${claimsLogin}"`, ]; const text = freeTextClause(query); if (text) clauses.push(text); 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 }; - traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); const entries = hitsToEntries(results, types); - const byKey = - entries.length > 0 - ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) - : new Map(); - const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); + const rows = entries.map(toMyRequestFromSearch); traceResultRows('Свои заявки', types, entries, rows); return rows; diff --git a/src/webparts/requestPortal/shared/perfTrace.ts b/src/webparts/requestPortal/shared/perfTrace.ts index b8f1792..fd35b09 100644 --- a/src/webparts/requestPortal/shared/perfTrace.ts +++ b/src/webparts/requestPortal/shared/perfTrace.ts @@ -67,12 +67,44 @@ interface IStat { 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 { - runs: number; - wallTotalMs: number; - stages: Map; - requests: Map; + wallSamples: number[]; + stages: Map; + requests: Map; } // Текущий (ещё не напечатанный) прогон. @@ -142,6 +174,18 @@ function installFlag(): void { set: (value: unknown): void => { enabled = !!value; writeStoredFlag(enabled); + if (!enabled) { + // Тишина СРАЗУ, а не после того, что уже накопилось. Без этого operация, + // стартовавшая ДО выключения (perfScope с сетевым шагом внутри) или + // ждущая debounce-таймера (см. scheduleIdleFlush), всё равно печатала + // итог ПОСЛЕ выключения — выглядело так, будто window.rpPerf = false + // не действует. + if (idleTimer !== undefined) { + window.clearTimeout(idleTimer); + idleTimer = undefined; + } + reset(); + } console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`); } }); @@ -181,7 +225,7 @@ function scheduleIdleFlush(): void { if (idleTimer !== undefined) window.clearTimeout(idleTimer); idleTimer = window.setTimeout(() => { idleTimer = undefined; - if (openScopes === 0) print(); + if (openScopes === 0 && perfEnabled()) print(); }, IDLE_FLUSH_MS); } @@ -223,7 +267,11 @@ export async function perfScope(label: string, fn: () => Promise): Promise return await fn(); } finally { 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 { return title.replace(/\s*стр\.\d+/g, '').trim(); } -function mergeInto(target: Map, source: Map): void { +function recordSamples(target: Map, source: Map): void { source.forEach((s, label) => { - const t = target.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 }; - t.calls += s.calls; - t.totalMs += s.totalMs; + const t = target.get(label) ?? { totalMs: [], calls: [], maxMs: 0 }; + pushSample(t.totalMs, s.totalMs); + pushSample(t.calls, s.calls); t.maxMs = Math.max(t.maxMs, s.maxMs); target.set(label, t); }); @@ -346,8 +394,10 @@ function totalCalls(source: Map): number { * window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть * есть на что. * - * Среднее в строке важнее текущего значения: одиночный сетевой замер шумит - * настолько, что по нему нельзя сказать, стало лучше или просто повезло. + * Медиана в строке важнее текущего значения: одиночный сетевой замер шумит + * настолько, что по нему нельзя сказать, стало лучше или просто повезло, а + * среднее в этом же шуме утаскивает один выброс (холодный кеш, ретрай на 429) + * далеко за пределы того, что происходит обычно. */ function print(): void { if (idleTimer !== undefined) { @@ -361,15 +411,13 @@ function print(): void { const key = historyKey(title); const hist: IRunHistory = history.get(key) ?? { - runs: 0, - wallTotalMs: 0, - stages: new Map(), - requests: new Map() + wallSamples: [], + stages: new Map(), + requests: new Map() }; - hist.runs += 1; - hist.wallTotalMs += wallMs; - mergeInto(hist.stages, stages); - mergeInto(hist.requests, requests); + pushSample(hist.wallSamples, wallMs); + recordSamples(hist.stages, stages); + recordSamples(hist.requests, requests); history.set(key, hist); // Дольше всего — по этапам, если разметка есть; иначе по видам запросов @@ -378,7 +426,7 @@ function print(): void { console.log( `${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` + - ` (сред. ${Math.round(hist.wallTotalMs / hist.runs)} за ${hist.runs})` + + ` (мед. ${Math.round(median(hist.wallSamples))} за ${hist.wallSamples.length})` + ` | запросов ${totalCalls(requests)}` + (top ? ` | дольше всего: ${top}` : '') ); @@ -386,18 +434,20 @@ function print(): void { reset(); } -/** Строки полной раскладки по одной операции — средние за все её прогоны. */ +/** Строки полной раскладки по одной операции — медианы за все её прогоны. */ 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): void => { + const dump = (name: string, source: Map): void => { if (source.size === 0) return; - lines.push(` ${name} (в среднем за прогон)`); + lines.push(` ${name} (медиана за прогон)`); 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]) => { 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; }; - // Старые прогоны тянут среднее назад, и «стало быстрее» проявится в нём - // только через несколько заходов — перед сравнением «до/после» серию надо - // обнулять. + // Старые прогоны остаются в окне (см. MAX_SAMPLES) и тянут медиану назад — + // «стало быстрее» проявится в ней только после того, как их наберётся + // больше половины окна. Перед сравнением «до/после» серию надо обнулять. w.rpPerfReset = (): void => { history.clear(); reset();