perf: бот-соавтор и скролл при соавторе
Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a1251436c3
commit
419a20e950
@ -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 записи — `<form_type>/<sheet>/<direction\|null>/coauthor<rate>@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<N>` | Троттлинг отключён по умолчанию, но `PERF_CPU_RATES=1,4,6` должен вести себя как прежде | Нет |
|
||||
|
||||
157
perf/e2e/scenarios/scroll-coauthor.spec.ts
Normal file
157
perf/e2e/scenarios/scroll-coauthor.spec.ts
Normal file
@ -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 по `<form_type>/<sheet>/<direction|null>`), 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<string, Sheet>();
|
||||
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<number> {
|
||||
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<Bot> {
|
||||
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<void>((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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
195
perf/perfkit/coauthor.py
Normal file
195
perf/perfkit/coauthor.py
Normal file
@ -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())
|
||||
@ -1,2 +1,4 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
markers =
|
||||
stand: нужен поднятый стенд (api :8000, БД с манифестом perf/results/manifest-s1.json)
|
||||
|
||||
43
perf/tests/test_coauthor.py
Normal file
43
perf/tests/test_coauthor.py
Normal file
@ -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} с"
|
||||
Loading…
x
Reference in New Issue
Block a user