From 419a20e9509d6b6e9461229a51485b2ea35546aa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 23:20:48 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=B1=D0=BE=D1=82-=D1=81=D0=BE=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=20=D0=B8=20=D1=81=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #202 Co-Authored-By: Claude Opus 5.5 --- openspec/changes/perf-profiling/design.md | 20 ++- perf/e2e/scenarios/scroll-coauthor.spec.ts | 157 +++++++++++++++++ perf/perfkit/coauthor.py | 195 +++++++++++++++++++++ perf/pytest.ini | 2 + perf/tests/test_coauthor.py | 43 +++++ 5 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 perf/e2e/scenarios/scroll-coauthor.spec.ts create mode 100644 perf/perfkit/coauthor.py create mode 100644 perf/tests/test_coauthor.py diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index c93e2e0..a3c3962 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -30,7 +30,7 @@ ## Ограничения и известные факты -- Машина заказчика сильно медленнее машины разработчика. Фронт мерить с замедлением CPU через CDP (`Emulation.setCPUThrottlingRate`) в двух режимах — 4× и 6×, а также без замедления для сравнения. +- Замеры без троттлинга CPU — решение человека 2026-10-25 (слабые места видны и на 1×); код throttle оставлен в perf/e2e/lib/cdp.ts для ручного включения. - Прод-сервер — около 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` не нужен (закомментирован в коде). @@ -58,8 +58,8 @@ - Генератор создаёт пользователей, оргединицы и этапы (`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` и могут перерисовывать таблицу во время скролла. Это один фоновый соавтор, а не нагрузочный тест. +1. **Скролл.** Для каждого листа каждой формы (FORM_1: AHR/CAP/OPER/SMETA с direction; FORM_2: основные листы и сателлиты; FORM_4: листы и STRUCTURE) и отчёта проекта (FORM_3): вертикальный скролл от начала до конца и обратно, горизонтальный по всем колонкам. Метрики: кадры и потерянные кадры, long tasks (>50 мс) и их суммарное время, время скриптов, стиля и layout из trace. Каждый прогон — на CPU 1×. +1a. **Скролл при живом соавторе.** Тот же скролл на базовом масштабе на CPU 1×, пока второй пользователь (скрипт через 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. @@ -76,7 +76,7 @@ ### Отчёт `perf/REPORT.md` По-русски, для заказчика и команды: 1. Короткое резюме: что тормозит, почему и насколько, топ-5 мест. -2. Методика и окружение: железо, версии, масштаб данных, режимы CPU, оговорка про прод-ресурсы. +2. Методика и окружение: железо, версии, масштаб данных, CPU 1× без троттлинга, оговорка про прод-ресурсы. 3. Таблица проблемных мест: № | место (`file:line`) | симптом | доказательство | влияние | приоритет (P1–P3) | подтверждено замером (да/нет). 4. Подробности по каждому месту. 5. Что не удалось проверить и почему. @@ -95,7 +95,7 @@ |---|---|---|---| | 2026-10-25 | Стенд коммитится в `perf/` в репозиторий | Нужен повтор замеров «после» на тех же данных | Лишний каталог в репо; удаляется одним коммитом | | 2026-10-25 | «Стенд не ограничивай» понято как «без лимитов CPU и памяти у бэка и БД» | Ближайшая трактовка реплики владельца сразу после обсуждения лимитов | Цифры бэка оптимистичнее прода; оговорено в отчёте | -| 2026-10-25 | Замедление CPU фронта 4× и 6× | Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна | Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется | +| 2026-10-25 | Замедление CPU фронта 4× и 6× (отменено решением человека 2026-10-25, см. раздел Ограничения) | Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна | Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется | | 2026-10-25 | Отчёт — markdown в `perf/REPORT.md` | Формат для заказчика владелец не выбрал; markdown легко превратить в документ | Переоформление отчёта | | 2026-10-25 | Масштаб данных задаёт исполнитель, плюс прогон на удвоенном масштабе | Реальные объёмы неизвестны | Неверная абсолютная величина; удвоение показывает чувствительность | | 2026-10-25 | `skip_specs: true` | Change не меняет поведение системы | — | @@ -140,3 +140,13 @@ | 2026-10-25 | Если строки появились, но их число не растёт 20 с и их < 20 — замер `incomplete:true` + `skip_reason` сразу, без ожидания `PERF_LOAD_TIMEOUT_MS` | Листы манифеста с 1 строкой (OTCH9F) иначе ждали бы 15 мин на каждый замер | Медленная отрисовка при простое > 20 с была бы принята за «мало строк» | | 2026-10-25 | Таймаут теста: 45 мин (`PERF_TEST_TIMEOUT_MS`), для свода 2 — 3 ч (`PERF_TEST_TIMEOUT_SVOD2_MS`) | Свод 2: загрузка до 15 мин + скролл на CPU 6 ≈ 40 мин; общий 45 мин обрывал бы его | Слишком большой таймаут задержит обнаружение зависания | | 2026-10-25 | Отсутствие переполнения по оси даёт `incomplete:true`, `skip_reason:'no_overflow'` (а не «зелёную» запись с 0 шагов); фильтр без изменения содержимого — `filter_no_effect`; повтор с трейсом помечен `metrics.traced=1` | Пустой замер не должен попасть в статистику; трейс искажает замер, Task 10 исключает такие записи | Нужно не забыть исключить traced=1 при агрегации | +| 2026-10-25 | Цели «скролла при соавторе» — самый большой rf-лист каждой доступной формы: FORM_2 (форма 7, AHR, 1154 строки) и FORM_4 (форма 13, STRUCTURE, 775); FORM_1 не берётся | FORM_1 принадлежит ССП: у `perf_rf`/`perf_rf_coauthor` его нет (GET листа форм 1 даёт 404) | FORM_1 без замера при соавторе | +| 2026-10-25 | Тест бота `test_coauthor_first_edit_echoed` идёт на первом rf-листе манифеста, а не на «первом листе FORM_1» | Тот же довод: соавтор не видит FORM_1 | Тест не проверяет FORM_1 | +| 2026-10-25 | Бот правит только числовые колонки (без `comment`/`justification`/`date`/`note` в имени) у строк `INPUT`, значение — случайное целое 1000–999999; колонки берёт из META.editable GET листа | Типы текстовых/датовых колонок не проверять; значения не имеют смысла, стенд тестовый | Данные листа меняются в ходе прогонов (суммы пересчитываются) | +| 2026-10-25 | «Эхо» = `cell_updated` с `is_self:true` без поля `error`; отказ сервера считается в `coauthor_errors`, не в `coauthor_echoed`; бот до первого эха повторяет правку (другие ячейки) раз в 0,7 с, но не дольше 5 с с запуска, иначе код 2 | Сервер при отказе возвращает то же событие с `error`; одиночный отказ по одной ячейке не должен ронять сценарий | При постоянных отказах сценарий падает через 5 с | +| 2026-10-25 | `--token` бота подменяет токен только для WS; чтение листа идёт по настоящему логину | Нужно проверить неверный токен (Review Focus), не сломав получение ячеек | Нет | +| 2026-10-25 | Запись `scroll_coauthor` не пишется (тест падает), если `coauthor_echoed==0` или страница автора не получила ни одного чужого `cell_updated` (`page_ws_updates==0`) | Иначе сценарий измерил бы «без соавтора» | Ложные падения при сбое WS-подключения страницы | +| 2026-10-25 | Target записи — `///coauthor@y`; бот запускается после загрузки листа и ждёт READY до старта замера; бот работает до SIGTERM после скролла; в метрики также `coauthor_errors`, `page_ws_updates`; трейс только на повторе 0 (`traced=1`) | Rate 1 и 5 не должны смешиваться в одну группу агрегатора | Task 10 исключает traced=1 | +| 2026-10-25 | Только вертикальный скролл (ось y), роль `rf` | Бриф: «скролл при соавторе»; горизонтальная ось не зависит от потока входящих правок | Нет замера оси x при соавторе | +| 2026-10-25 | Вспомогательный `waitRows` продублирован в `scroll-coauthor.spec.ts`, а не вынесен в lib/ | Не трогать `scroll.spec.ts` Task 5; функция короткая | Дубли при правке | +| 2026-10-25 | Фильтр свода по умолчанию мерится на `max(PERF_CPU_RATES)` (при умолчании `1`), имя теста `filter cpu` | Троттлинг отключён по умолчанию, но `PERF_CPU_RATES=1,4,6` должен вести себя как прежде | Нет | diff --git a/perf/e2e/scenarios/scroll-coauthor.spec.ts b/perf/e2e/scenarios/scroll-coauthor.spec.ts new file mode 100644 index 0000000..c237e96 --- /dev/null +++ b/perf/e2e/scenarios/scroll-coauthor.spec.ts @@ -0,0 +1,157 @@ +/** + * Сценарий «скролл при живом соавторе» (Task 6): автор perf_rf (role rf) скроллит лист, пока бот perf_rf_coauthor + * правит ячейки того же листа по WebSocket с частотой rate правок/с (perf/perfkit/coauthor.py). + * + * Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `//`), PERF_RUN_ID, + * PERF_REPEATS (3), PERF_CPU_RATES ('1'; троттлинг отключён решением человека, PERF_CPU_RATES=6 включает вручную), PERF_COAUTHOR_RATES ('1,5'), PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, + * PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python). + * Цели: самый большой лист каждой формы из доступных роли rf (owner=rf). + * Запись не пишется (тест падает), если бот не получил эха своих правок или страница не увидела ни одного cell_updated. + */ +import { test, type Page } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import { loadManifest, scale, PERF_DIR, type Sheet } from '../lib/manifest'; +import { loginAs, WEB } from '../lib/auth'; +import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe'; +import { throttle, startTrace, stopTrace } from '../lib/cdp'; +import { append, runDir } from '../lib/results'; +import { scrollThrough } from '../lib/scroll'; + +const CONTAINER = '.MuiTableContainer-root'; +const MIN_ROWS = 20; +const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); +const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000); +const PYTHON = process.env.PERF_PYTHON ?? path.join(PERF_DIR, '.venv', 'bin', 'python'); +const BOT_READY_MS = 30_000; + +const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d); +const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number); +const CO_RATES = envList('PERF_COAUTHOR_RATES', ['1', '5']).map(Number); +const REPEATS = Number(process.env.PERF_REPEATS ?? 3); +const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); +const TARGET_RES = envList('PERF_TARGETS', ['*']).map(globRe); + +const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; +const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); + +/** Самый большой лист каждой формы (form_type) среди owner=rf. */ +function targets(): Sheet[] { + const best = new Map(); + for (const s of loadManifest().sheets) { + if (s.owner !== 'rf') continue; + const cur = best.get(s.form_type); + if (!cur || s.rows > cur.rows) best.set(s.form_type, s); + } + return [...best.values()].filter((s) => TARGET_RES.some((r) => r.test(sheetName(s)))); +} + +async function waitRows(page: Page): Promise { + const t0 = Date.now(); + for (;;) { + const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); + if (n >= MIN_ROWS) return n; + if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`); + await page.waitForTimeout(500); + } +} + +interface Bot { proc: ChildProcess; stop: () => Promise<{ sent: number; echoed: number; errors: number; code: number | null }> } + +/** Запускает бота и ждёт строку READY (эхо первой правки). Нет READY за BOT_READY_MS или выход бота - исключение. */ +async function startBot(s: Sheet, rate: number): Promise { + const args = ['-m', 'perfkit.coauthor', '--form-id', String(s.form_id), '--sheet', s.sheet, '--rate', String(rate), '--duration', '86400', '--scale', String(scale())]; + if (s.direction) args.push('--direction', s.direction); + const proc = spawn(PYTHON, args, { cwd: PERF_DIR, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', err = ''; + let exited: number | null | undefined; + const exitP = new Promise((res) => proc.on('exit', (c) => { exited = c; res(); })); + proc.stdout!.on('data', (d) => { out += d; }); + proc.stderr!.on('data', (d) => { err += d; }); + const t0 = Date.now(); + while (!out.includes('READY')) { + if (exited !== undefined) throw new Error(`бот-соавтор завершился (код ${exited}) до эха первой правки: ${err.trim()}`); + if (Date.now() - t0 > BOT_READY_MS) { proc.kill('SIGKILL'); throw new Error(`бот-соавтор не получил эха за ${BOT_READY_MS} мс: ${err.trim()}`); } + await new Promise((r) => setTimeout(r, 100)); + } + return { + proc, + stop: async () => { + if (exited === undefined) proc.kill('SIGTERM'); + await Promise.race([exitP, new Promise((r) => setTimeout(r, 10_000))]); + if (exited === undefined) proc.kill('SIGKILL'); + const last = out.trim().split('\n').filter((l) => l.startsWith('{')).pop(); + const st = last ? JSON.parse(last) : { sent: 0, echoed: 0, errors: 0 }; + return { ...st, code: exited ?? null }; + }, + }; +} + +async function measure(browser: import('@playwright/test').Browser, s: Sheet, cpu: number, rate: number, repeat: number) { + const m = loadManifest(); + const name = sheetName(s); + const targetName = `${name}/coauthor${rate}@y`; + const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); + const artifacts: string[] = []; + let bot: Bot | undefined; + try { + await loginAs(ctx, m.users['rf']); + await ctx.addInitScript(PROBE_SCRIPT); + const page = await ctx.newPage(); + // сколько cell_updated другого пользователя дошло до страницы автора (WS должен быть подключён) + let pageUpdates = 0; + page.on('websocket', (ws) => ws.on('framereceived', (f) => { + const p = typeof f.payload === 'string' ? f.payload : f.payload.toString(); + if (!p.includes('cell_updated')) return; + try { const d = JSON.parse(p); if (d.event === 'cell_updated' && d.is_self === false && !d.error) pageUpdates++; } catch { /* не JSON */ } + })); + await page.goto(WEB + s.url); + const rows = await waitRows(page); + await page.waitForTimeout(500); + await throttle(page, cpu); + + bot = await startBot(s, rate); + const trace = repeat === 0; + const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`); + if (trace) await startTrace(page); + await page.evaluate(() => (window as any).__perf.start()); + const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS); + const probe: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop()); + if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); } + const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); + + const co = await bot.stop(); + bot = undefined; + if (co.echoed === 0) throw new Error(`coauthor_echoed=0 (sent=${co.sent}, errors=${co.errors}, код бота ${co.code}): сценарий измерил бы «без соавтора»`); + if (pageUpdates === 0) throw new Error('страница автора не получила ни одного cell_updated соавтора (WS не подключён?)'); + const reason = r.reason ?? (r.complete ? undefined : 'scroll_incomplete'); + append({ + scenario: 'scroll_coauthor', target: targetName, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, + metrics: { + ...probe, axis: 0, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, steps: r.steps, + coauthor_rate: rate, coauthor_sent: co.sent, coauthor_echoed: co.echoed, coauthor_errors: co.errors, page_ws_updates: pageUpdates, + }, + artifacts, + incomplete: !r.complete, + ...(reason ? { skip_reason: reason } : {}), + }); + } finally { + if (bot) await bot.stop().catch(() => {}); + await ctx.close(); + } +} + +test.describe('scroll_coauthor', () => { + for (const s of targets()) { + for (const cpu of CPU_RATES) { + for (const rate of CO_RATES) { + for (let repeat = 0; repeat < REPEATS; repeat++) { + test(`${sheetName(s)} rf y cpu${cpu} coauthor${rate} #${repeat}`, async ({ browser }) => { + test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000)); + await measure(browser, s, cpu, rate, repeat); + }); + } + } + } + } +}); diff --git a/perf/perfkit/coauthor.py b/perf/perfkit/coauthor.py new file mode 100644 index 0000000..91aad8d --- /dev/null +++ b/perf/perfkit/coauthor.py @@ -0,0 +1,195 @@ +"""Бот-соавтор: правит редактируемые ячейки листа по WebSocket с заданной частотой. + +CLI: python -m perfkit.coauthor --form-id N --sheet S [--direction D] --rate 1|5 --duration SEC + [--user rf_coauthor] [--scale 1] [--token T] + +Протокол (api/src/api/v1/websocket.py, web/.../RealtimeContext.js): + ws://HOST/api/v1/ws/form/{id}/sheet/{sheet}?direction=D + -> {"event":"user_login","data":{"token":...}} + -> {"event":"cell_edit_start","data":{"line_id","column"}} (эхо отправителю, блокировка ячейки) + -> {"event":"cell_updated","data":{"line_id","line_id_code","column","value"}} + (эхо отправителю с "is_self": true; при отказе то же событие с полем "error") + -> {"event":"cell_edit_end","data":{"line_id","column"}} +Редактируемые колонки берутся из строки META листа (GET sheet: data.editable, роль EXECUTOR_RF), +правятся числовые колонки у строк типа INPUT. + +Коды выхода: 0 - были подтверждённые эхом правки; 2 - нет эха первой правки за --ack-timeout (в т.ч. неверный токен +или закрытое соединение); 3 - ошибка подготовки (вход по HTTP, нет редактируемых ячеек). +Печатает "READY" после первого эха и в конце последней строкой JSON {"sent","echoed","errors"}. +SIGTERM/SIGINT завершают работу штатно (итог печатается). +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import random +import signal +import sys +import time +from urllib.parse import urlencode + +import httpx +import websockets + +from . import RESULTS_DIR +from .manifest import load as load_manifest + +API = os.environ.get("PERF_API", "http://localhost:8000") +TEXT_HINTS = ("comment", "justification", "date", "note") # нечисловые колонки не трогаем + + +def log(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +def pick_cells(rows: list[dict]) -> list[tuple[int, str]]: + """(line_id, column) для INPUT-строк в числовых редактируемых колонках META.""" + meta = next((r for r in rows if r.get("row_type") == "META"), None) + if not meta: + return [] + cols = [e["column"] for e in meta["data"].get("editable", []) if not any(h in e["column"] for h in TEXT_HINTS)] + cells = [] + for r in rows: + if r.get("row_type") != "INPUT": + continue + d = r.get("data", {}) + lid = d.get("line_id") or d.get("id") + if lid is None: + continue + cells.extend((lid, c) for c in cols) + return cells + + +async def fetch_cells(args) -> list[tuple[int, str]]: + m = load_manifest(RESULTS_DIR / f"manifest-s{args.scale}.json") + u = m.users[args.user] + async with httpx.AsyncClient(base_url=API + "/api/v1", timeout=120) as c: + r = await c.post("/auth/login", json={"username": u.username, "password": u.password}) + r.raise_for_status() + tok = r.json()["access_token"] + params = {"direction": args.direction} if args.direction else None + r = await c.get(f"/form/{args.form_id}/sheet/{args.sheet}", params=params, headers={"Authorization": f"Bearer {tok}"}) + r.raise_for_status() + args._http_token = tok + return pick_cells(r.json()["result"]) + + +def ws_url(args) -> str: + base = API.replace("http://", "ws://").replace("https://", "wss://") + q = "?" + urlencode({"direction": args.direction}) if args.direction else "" + return f"{base}/api/v1/ws/form/{args.form_id}/sheet/{args.sheet}{q}" + + +async def run(args) -> int: + t0 = time.monotonic() + try: + cells = await fetch_cells(args) + except Exception as e: # noqa: BLE001 + log(f"coauthor: подготовка не удалась: {e!r}") + return 3 + if not cells: + log("coauthor: нет редактируемых ячеек для роли пользователя") + return 3 + + st = {"sent": 0, "echoed": 0, "errors": 0} + first_echo = asyncio.Event() + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, stop.set) + + token = args.token if args.token is not None else args._http_token + try: + ws = await asyncio.wait_for(websockets.connect(ws_url(args), max_size=None), 5) + except Exception as e: # noqa: BLE001 + log(f"coauthor: WS недоступен: {e!r}") + return 2 + + async def reader(): + try: + async for raw in ws: + msg = json.loads(raw) + if msg.get("event") != "cell_updated" or not msg.get("is_self"): + continue + if msg.get("error"): + st["errors"] += 1 + if st["errors"] <= 3: + log(f"coauthor: отказ сервера: {msg['error']}") + continue + st["echoed"] += 1 + if not first_echo.is_set(): + first_echo.set() + print("READY", flush=True) + except websockets.ConnectionClosed as e: + log(f"coauthor: соединение закрыто: {e.rcvd}") + finally: + stop.set() + + rd = asyncio.create_task(reader()) + await ws.send(json.dumps({"event": "user_login", "data": {"token": token}})) + + async def edit(): + lid, col = random.choice(cells) + key = {"line_id": lid, "column": col} + await ws.send(json.dumps({"event": "cell_edit_start", "data": key})) + await ws.send(json.dumps({"event": "cell_updated", "data": {**key, "line_id_code": lid, "value": random.randint(1000, 999_999)}})) + st["sent"] += 1 + await ws.send(json.dumps({"event": "cell_edit_end", "data": key})) + + rc = 0 + try: + # фаза 1: до первого эха, не дольше ack_timeout с момента старта процесса + while not first_echo.is_set(): + if stop.is_set() or time.monotonic() - t0 > args.ack_timeout: + log("coauthor: нет эха первой правки (неверный токен, нет прав или окно закрыто)") + rc = 2 + break + await edit() + try: + await asyncio.wait_for(first_echo.wait(), 0.7) + except asyncio.TimeoutError: + pass + if rc == 0: + # фаза 2: ровная частота rate правок/с до конца duration + period = 1.0 / args.rate + end = time.monotonic() + args.duration + nxt = time.monotonic() + period + while not stop.is_set() and time.monotonic() < end: + await asyncio.sleep(max(0.0, min(nxt, end) - time.monotonic())) + if time.monotonic() >= end or stop.is_set(): + break + await edit() + nxt += period + await asyncio.sleep(0.5) # дождаться эха последних правок + if st["echoed"] == 0: + rc = 2 + except websockets.ConnectionClosed: + rc = 2 if st["echoed"] == 0 else 0 + finally: + try: + await ws.close() + except Exception: # noqa: BLE001 + pass + rd.cancel() + print(json.dumps(st), flush=True) + return rc + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--form-id", type=int, required=True) + ap.add_argument("--sheet", required=True) + ap.add_argument("--direction", default=None) + ap.add_argument("--rate", type=float, default=1.0, help="правок в секунду (1 или 5)") + ap.add_argument("--duration", type=float, default=10.0, help="секунд работы; SIGTERM завершает раньше") + ap.add_argument("--user", default="rf_coauthor", help="ключ пользователя в манифесте") + ap.add_argument("--scale", type=int, default=int(os.environ.get("PERF_SCALE", 1))) + ap.add_argument("--token", default=None, help="токен для WS вместо полученного входом (проверка неверного токена)") + ap.add_argument("--ack-timeout", type=float, default=5.0, help="сколько ждать эха первой правки") + return asyncio.run(run(ap.parse_args(argv))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/perf/pytest.ini b/perf/pytest.ini index a635c5c..7d99eb8 100644 --- a/perf/pytest.ini +++ b/perf/pytest.ini @@ -1,2 +1,4 @@ [pytest] pythonpath = . +markers = + stand: нужен поднятый стенд (api :8000, БД с манифестом perf/results/manifest-s1.json) diff --git a/perf/tests/test_coauthor.py b/perf/tests/test_coauthor.py new file mode 100644 index 0000000..6d52ce2 --- /dev/null +++ b/perf/tests/test_coauthor.py @@ -0,0 +1,43 @@ +"""Интеграционные тесты бота-соавтора (нужен поднятый стенд): pytest -m stand tests/test_coauthor.py""" +import json +import subprocess +import sys +import time + +import pytest + +from perfkit import PERF_DIR, RESULTS_DIR +from perfkit.manifest import load + +pytestmark = pytest.mark.stand + + +def _sheet(): + m = load(RESULTS_DIR / "manifest-s1.json") + # соавтор - rf_coauthor, права только на листы owner=rf (FORM_1 принадлежит ССП) + first = next(s for s in m.sheets if s.owner == "rf") + return first + + +def _run(*extra, timeout=30): + s = _sheet() + cmd = [sys.executable, "-m", "perfkit.coauthor", "--form-id", str(s.form_id), "--sheet", s.sheet, *extra] + if s.direction: + cmd += ["--direction", s.direction] + t0 = time.monotonic() + p = subprocess.run(cmd, cwd=PERF_DIR, capture_output=True, text=True, timeout=timeout) + return p, time.monotonic() - t0 + + +def test_coauthor_first_edit_echoed(): + p, _ = _run("--rate", "1", "--duration", "3") + assert p.returncode == 0, p.stderr + stats = json.loads(p.stdout.strip().splitlines()[-1]) + assert stats["echoed"] >= 1 + assert stats["sent"] >= stats["echoed"] + + +def test_coauthor_bad_token_fails_fast(): + p, dt = _run("--rate", "1", "--duration", "3", "--token", "not-a-token") + assert p.returncode == 2, p.stderr # 2 - нет эха первой правки (в т.ч. неверный токен) + assert dt <= 5.5, f"бот завершился за {dt:.1f} с"