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

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

129 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | Библиотеки не попадут в коммит / мусор в коммите |