Andrey Epifancev efa537e008 perf: каркас замеров фронта
Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:12:36 +04:00

25 KiB
Raw Blame History

Design: профилирование фронта и бэка (#202)

Зачем

Заказчик сообщил (пересказом, без деталей), что:

  1. тормозит скролл — предположительно любой большой формы, там много скроллить;
  2. модальное окно по нажатию кнопки открывается не сразу.

Бэк ранее профилировался базово, поэтому основное подозрение — фронт, включая утечки памяти. Задача — подсветить проблемные места с доказательствами. Исправлять ничего не нужно: исправления пойдут отдельными задачами по результатам отчёта.

Цели

  • Воспроизводимый локальный стенд, поднимаемый одной-двумя командами, чтобы позже снять замеры «после» на тех же данных и сценариях.
  • Замеры по двум симптомам заказчика и по памяти, для фронта и для бэка, которые обслуживают эти сценарии.
  • Отчёт: список проблемных мест, у каждого — симптом, доказательство (цифры и ссылка на артефакт: trace, профиль, heap snapshot, EXPLAIN), место в коде (file:line), оценка влияния, приоритет.

Не цели

  • Исправления кода приложения, даже очевидные.
  • Нагрузочное тестирование многими пользователями.
  • Интеграция замеров в CI.
  • Воспроизведение прод-ресурсов на стенде (см. «Ограничения»).

Анти-цели (провал, даже если формально сделано)

  • Выводы без замера. Находки статического анализа допустимы только с явной пометкой «не подтверждено замером» и отдельно от подтверждённых.
  • Замеры только на dev-сборке фронта (rsbuild dev): она искажает картину React.
  • Правки в api/src, api/alembic, web/src, web/rsbuild.config.ts или в зависимостях приложения ради замеров. Всё, что нужно стенду, живёт в perf/.
  • Цифры, которые нельзя повторить: каждая цифра отчёта получается командой из perf/.

Ограничения и известные факты

  • Машина заказчика сильно медленнее машины разработчика. Фронт мерить с замедлением CPU через CDP (Emulation.setCPUThrottlingRate) в двух режимах — 4× и 6×, а также без замедления для сравнения.
  • Прод-сервер — около 1 vCPU / 4 GB (raisa_config.yml устарел). Стенд по указанию владельца ресурсами не ограничивается. Цифры бэка в отчёте помечаются как полученные на быстром железе, без лимитов.
  • БД — только Postgres, схема v3. SQLite не годится: миграции на PL/pgSQL. Postgres 16 в docker (образ доступен).
  • Зависимости: npm берётся из публичного registry, для Python — api/requirements-dev.txt, без корпоративного Nexus. raisa-fastapi-protected-api не нужен (закомментирован в коде).
  • При DEBUG=true миграции на старте не выполняются. Стенд сам делает cd api && alembic upgrade head. Авторизация — локальный JWT (/api/v1/auth/login).
  • cwd бэка — api/. Собранный фронт API отдаёт из api/web — либо так, либо через отдельный статический сервер; на выбор исполнителя, лишь бы это была prod-сборка.
  • Таблица листа (web/src/components/RealtimeTable/RealtimeTable.jsx) уже использует виртуализацию строк и колонок material-react-table. Гипотезу «нет виртуализации» не проверять как основную.
  • Состояние WebSocket-блокировок в памяти процесса — бэк в один процесс.
  • Браузер — Chromium (Playwright), методика на CDP. У заказчика Яндекс.Браузер (Chromium) — цифры переносимы. В методике отчёта зафиксировать версию Chromium стенда и оговорку: расширения и собственные фоновые процессы Яндекс.Браузера стенд не воспроизводит.

Что решено

Стенд (perf/)

  • Postgres 16 в docker, миграции alembic до head, затем генерация данных.
  • Бэк — uvicorn из api/ в одном процессе, без лимитов. Фронт — npm run build, отдача prod-сборки.
  • Для React Profiler — отдельная профилирующая сборка (alias react-dom → react-dom/profiling; scheduler/tracing в React 18 нет) через собственный конфиг rsbuild в perf/, который расширяет web/rsbuild.config.ts и не меняет его. Основные цифры (время, FPS, long tasks) снимаются на обычной prod-сборке; профилирующая сборка нужна только для ответа «что перерисовывается».
  • perf/README.md: как поднять стенд, сгенерировать данные, прогнать сценарии и где лежат результаты.

Данные

  • Объём заказчик не сообщил. Генератор параметризуется масштабом. Базовый масштаб — «большой, но правдоподобный»: формы FORM_1, FORM_2, FORM_4 за один год по оргединицам, в листах — строки по всему дереву статей расходов из сида (0003_initial_data.sql), строки ВСП и проекты для FORM_3/FORM_4. Выбранные числа и их обоснование исполнитель записывает в отчёт.
  • Второй прогон на удвоенном масштабе показывает, как время растёт с объёмом.
  • Данные создаются через те же SQL-функции v3.add_*, что использует приложение, где это практично. Прямые INSERT — только там, где функции нет; в этом случае генератор не должен нарушать инварианты, которые обеспечивают функции.

Роль и этапы

  • Основная роль замеров — EXECUTOR_RF с открытым окном этапа на правку (жалуется, скорее всего, исполнитель): таблица рендерит колонки и права на строки по роли и этапу (RealtimeTable.jsx:111, :263–273), модалки выбора ВСП и статьи доступны только при праве править строки. Контрольный прогон — ADMIN, только для скролла на базовом масштабе.
  • Генератор создаёт пользователей, оргединицы и этапы (form_phase) с окнами, открытыми на дату замера.

Сценарии фронта (Playwright + CDP)

  1. Скролл. Для каждого листа каждой формы (FORM_1: AHR/CAP/OPER/SMETA с direction; FORM_2: основные листы и сателлиты; FORM_4: листы и STRUCTURE) и отчёта проекта (FORM_3): вертикальный скролл от начала до конца и обратно, горизонтальный по всем колонкам. Метрики: кадры и потерянные кадры, long tasks (>50 мс) и их суммарное время, время скриптов, стиля и layout из trace. Каждый прогон — в трёх режимах CPU. 1a. Скролл при живом соавторе. Тот же скролл на базовом масштабе в режиме 6×, пока второй пользователь (скрипт через WebSocket, без браузера) правит ячейки этого листа с заданной частотой (1 и 5 правок/с). Входящие cell_updated/cell_edit_start/row_added обрабатываются в RealtimeContext.js:105–160 и могут перерисовывать таблицу во время скролла. Это один фоновый соавтор, а не нагрузочный тест. 1b. Своды (вторым приоритетом после листов форм): свод 1 (pages/SvodPage/SummaryTable.jsx), свод 2 (pages/Svod2Page/Svod2Page.jsx), ProjectSummariesPage — скролл и смена фильтров; данные генератора должны давать правдоподобно заполненные своды по всем оргединицам. SQL сводных функций — в замеры бэка.
  2. Модалки. Перечень строится по коду: все модалки и диалоги, доступные со страниц форм, задачи и проекта — этапы (SettingModal), добавление года, экспорт, редактирование проекта, создание проекта и т.д. Модалки админки — вторым приоритетом. Метрика: от клика до первого кадра с видимой модалкой (requestAnimationFrame после появления в DOM), long tasks в этом окне, какие компоненты перерисовались (по профилирующей сборке). Отдельно — открытие модалки поверх открытого большого листа, если такое есть.
  3. Память. Длительный сценарий — циклы «открыть лист → скролл → правка ячеек через WebSocket → уйти на список задач → открыть другой лист», около 20 циклов. Метрики: JSHeapUsedSize по циклам после принудительного GC, heap snapshots в начале, середине и конце, detached DOM nodes, число слушателей событий, число открытых WebSocket. Рост между снимками разбирается до retainers.

Замеры бэка

  • Время ответа каждого запроса, который фронт делает в сценариях выше (из сетевого лога Playwright), плюс отдельный прогон этих же запросов без браузера — медиана и p95 по N повторам.
  • pg_stat_statements за время сценариев: топ запросов и функций по суммарному и среднему времени. Для топа — EXPLAIN (ANALYZE, BUFFERS).
  • Размер ответов: тяжёлый JSON листа влияет и на фронт.
  • Профиль Python (py-spy) — только если время ответа заметно больше времени SQL.

Статический разбор

Параллельно замерам — разбор кода фронта по местам, на которые указывают профили: нестабильные значения контекстов, пересоздание колонок и конфигов на рендер, слушатели и подписки без очистки, таймеры, WebSocket без закрытия. Каждая находка ссылается на file:line и помечена: подтверждена замером или нет.

Отчёт perf/REPORT.md

По-русски, для заказчика и команды:

  1. Короткое резюме: что тормозит, почему и насколько, топ-5 мест.
  2. Методика и окружение: железо, версии, масштаб данных, режимы CPU, оговорка про прод-ресурсы.
  3. Таблица проблемных мест: № | место (file:line) | симптом | доказательство | влияние | приоритет (P1–P3) | подтверждено замером (да/нет).
  4. Подробности по каждому месту.
  5. Что не удалось проверить и почему.

Сырые артефакты (traces, heap snapshots) складываются в perf/results/<дата>/ и в git не коммитятся (.gitignore). Коммитятся сводные JSON с цифрами и отчёт.

На усмотрение исполнителя

  • Язык и инструменты генератора данных (Python + asyncpg или SQL), структура perf/, docker compose или скрипты.
  • Точные числа масштаба (с обоснованием в отчёте), число повторов, пороги.
  • Как обходить UI в сценариях (селекторы, прямые URL страниц).

Decisions made without discussion

Дата Решение Обоснование Цена ошибки
2026-10-25 Стенд коммитится в perf/ в репозиторий Нужен повтор замеров «после» на тех же данных Лишний каталог в репо; удаляется одним коммитом
2026-10-25 «Стенд не ограничивай» понято как «без лимитов CPU и памяти у бэка и БД» Ближайшая трактовка реплики владельца сразу после обсуждения лимитов Цифры бэка оптимистичнее прода; оговорено в отчёте
2026-10-25 Замедление CPU фронта 4× и 6× Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется
2026-10-25 Отчёт — markdown в perf/REPORT.md Формат для заказчика владелец не выбрал; markdown легко превратить в документ Переоформление отчёта
2026-10-25 Масштаб данных задаёт исполнитель, плюс прогон на удвоенном масштабе Реальные объёмы неизвестны Неверная абсолютная величина; удвоение показывает чувствительность
2026-10-25 skip_specs: true Change не меняет поведение системы —
2026-10-25 Основная роль — EXECUTOR_RF с открытым окном, ADMIN — контрольный прогон скролла Жалобы, скорее всего, от исполнителей; рендер зависит от роли и этапа Замеры на конфигурации, которой нет у заказчика; ADMIN-прогон показывает разницу
2026-10-25 Добавлен сценарий «скролл при одном фоновом соавторе» (1 и 5 правок/с), но не нагрузочный тест Совместная работа — основной режим системы, входящие WS-события перерисовывают таблицу +1 сценарий; если не влияет — так и будет написано
2026-10-25 Своды 1, 2 и сводные проектов — в скоупе вторым приоритетом Пользователь может называть свод «формой»; это тяжёлые таблицы Больше работы по данным и сценариям
2026-10-25 Postgres 16 Версия прод-БД неизвестна; 16 — актуальная стабильная Мелкие расхождения планов запросов с продом
2026-10-25 R-1 (design #11): Task 3 заполняет svods/modal_pages манифеста по маршрутам из Routes.jsx Иначе сценарии T5/T7 не получат список страниц Доработка T3 при T5/T7
2026-10-25 Email админа стенда admin@perf-stand.com вместо admin@perf.local /api/v1/users/me падает 500: email-validator отклоняет .local в response_model Другой email у админа стенда; на замеры не влияет
2026-10-25 В perf/requirements.txt добавлен greenlet api/requirements-dev.txt его не содержит, alembic на asyncpg падает без него Лишняя зависимость в venv стенда
2026-10-25 perf/web/rsbuild.perf.config.ts импортирует @rsbuild/core по относительному пути ../../web/node_modules/@rsbuild/core Конфиг в perf/ не видит web/node_modules через резолв пакетов Путь ломается при смене раскладки каталогов
2026-10-25 В .gitignore шаблон для трейсов perf/**/*trace*.json Формулировка «*.json trace» неоднозначна Лишний или пропущенный файл в игноре
2026-10-25 В .gitignore добавлено исключение !perf/docker-compose.yml Корневой .gitignore игнорирует docker-compose.yml Файл стенда не попадёт в коммит
2026-10-25 В манифесте сегмент «нет direction» в URL листа — литерал null TableList.jsx строит путь через ${table?.direction}; при null из API получается null (при undefined было бы undefined) Переходы на листы без direction дадут 404/пустую страницу
2026-10-25 Агрегатор считает median чётного n как среднее двух центральных, p95 — nearest-rank; нечисловые и bool-метрики игнорируются Бриф задаёт nearest-rank только для p95; median по стандартному определению Небольшое расхождение чисел с ручным расчётом
2026-10-25 results.append валидирует наличие всех обязательных ключей записи Ранний отказ на кривом формате от Node-стороны Лишний ValueError при расширении формата
2026-10-25 План масштаба 1: 3 ССП, 6 РФ, 30 ВСП/РФ, 500 доп. строк на лист (FORM_4 — 100), 4 проекта/РФ, fill_ratio 0.6; масштаб 2 удваивает все поля, включая org_units_ssp Самый большой лист rf (FORM_2/AHR) >= 1000 строк; генерация масштаба 1 в пределах получаса; обоснование в perf/perfkit/gen/plan.py Другой объём данных; масштаб 2 займёт ~4-5x масштаба 1
2026-10-25 Строк на листах FORM_4 в 5 раз меньше, чем на листах FORM_1/2 (FORM4_LINES_DIVISOR) add_line и правка ячеек FORM_4 стоят O(число строк листа) в БД (0.2 с -> 1.6 с на 300 строках, батч 200 ячеек 9-11 с); 500 строк на лист сделали бы генерацию многочасовой Листы FORM_4 в замерах меньше, чем у заказчика; ищется там же отдельно
2026-10-25 Формы: FORM_1 по ССП, FORM_2 по ССП и РФ, FORM_4 по РФ Свод 2 «РФ» читает FORM_2 РФ (миграция 0041), FORM_4 — форма РФ, FORM_1 — ГО Своды заполнены иначе, чем в проде
2026-10-25 Всё заполнение (строки, ячейки) идёт под admin, этапы EXECUTOR_RF/DFIP с окном now-1d..now+30d создаются после заполнения (фазы PERF_RF/PERF_DFIP); колонки этапа — те, что реально записываются на листе У admin нет ограничений по этапам, быстрее и надёжнее; в конце самопроверка правки под rf, rf_coauthor, dfip Окно этапа открыто только на колонки заполнения (plan, actual, reserve, ...), не на все
2026-10-25 Логины пользователей perf_rf, perf_rf_coauthor, perf_dfip (ключи манифеста rf, rf_coauthor, dfip) username в API не короче 3 символов, rf отвергается Сценарии должны брать логин из манифеста, а не из ключа
2026-10-25 Программы и проекты FORM_4 создаются через WebSocket (program_added, project_added) HTTP-ручки нет; WS — тот же путь, что у фронта, и те же SQL-функции add_form4_* Зависимость генератора от протокола WS
2026-10-25 В Sheet манифеста добавлено поле owner ("rf"/"ssp", по умолчанию "rf"); в манифест попадают листы только ССП-01 и РФ-01 Манифест без признака владельца не позволяет отличить листы, недоступные rf; полный список форм раздул бы сценарии Сценарии T5 должны фильтровать по owner
2026-10-25 Лист свода кодируется в Page.url после # (/svod#FORM_1, /svod/2#RF) Вкладка свода — состояние страницы, не маршрут; хеш не влияет на роутинг, имена svod1_*, svod2_* уникальны Сценарий обязан выбирать вкладку по хешу/имени
2026-10-25 В perf/stand.sh добавлена reset; start_bg отвязывает подоболочку от stdout Требование задачи; без отвязки stand.sh api | tail висел Правка поведения стенда Task 1
2026-10-25 Версии @playwright/test, @types/node, typescript в perf/e2e/package.json зафиксированы точно (без ^) Воспроизводимость замеров между прогонами «до/после» Обновление версий вручную
2026-10-25 Тест results format пишет в каталог через PERF_RESULTS_DIR и после проверки удаляет perf/report-data/<имя> aggregate пишет в perf/report-data/<run_id>; не должно оставаться мусора Мусор в report-data при падении теста
2026-10-25 probe.stop() ждёт 100 мс перед сбором (PerformanceObserver доставляет longtask асинхронно) Иначе последняя длинная задача перед stop теряется +100 мс к duration_ms
2026-10-25 heapUsed делает GC перед Runtime.getHeapUsage; startTrace/stopTrace через browser.startTracing Playwright Стабильные значения кучи; штатный API трассы Трасса содержит события всего браузера
2026-10-25 В .gitignore добавлены !perf/e2e/lib/ и perf/e2e/test-results/ Корневой lib/ игнорирует каталог библиотек каркаса; test-results — артефакты Playwright Библиотеки не попадут в коммит / мусор в коммите