diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index da6319f..f357224 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -162,6 +162,9 @@ | 2026-10-26 | Строки grow пустые (значения не заполняются), FORM_2: дубли ВСП допустимы (API принимает) | Для открытия модалки важно число строк в DOM/таблице, не данные | Нет | | 2026-10-26 | Время grow на масштабе 1: FORM_2 +468/+469 строк по ~1 c (параллельно), FORM_4 CAP +190 строк 213 c (последовательно, ~1.1 c на строку, O(n)) | Замер | Для масштаба 2 цели те же - время не растёт | | 2026-10-26 | Порядок run-all (предложение для T10): reset -> gen -> скролл -> соавтор -> grow -> модалки (малые и @big) -> память | grow меняет размер листов | Решение за T10 | +| 2026-10-26 | Сценарий памяти: вместо «самого большого листа FORM_1» берётся самый большой лист FORM_2 с owner=rf (AHR, 1154 строки; на нём 3 правки за цикл), вместо FORM_2 - самый большой лист FORM_4 с owner=rf (STRUCTURE, 775 строк; только открытие и скролл, правок нет). Переопределение: `PERF_MEM_SHEET1/2` | У роли rf нет листов FORM_1 (они ССП, 404/нет прав) | Нагрузка цикла отличается от формулировки брифа; пара листов меняется одной переменной | +| 2026-10-26 | Правка ячейки в сценарии памяти - двойной клик (material-react-table, editDisplayMode cell), числа 1000+cycle*10+i, Enter; успех = эхо cell_updated is_self без error + закрытие редактора. Скролл `scrollThrough` идёт вниз и обратно | Так редактор открывается в приложении; эхо подтверждает прохождение через фронтовый WS | Значения ячеек rf на листах меняются (стенд тестовый) | +| 2026-10-26 | Навигация между страницами - history.pushState + popstate, перед выборкой проверяется маркер `window.__sessionMark`; перед каждым циклом access-токен перевыпускается через API, если до истечения (60 мин, ACCESS_TOKEN_EXPIRE_MINUTES) меньше 10 мин | Одна SPA-сессия; полный сценарий дольше жизни токена (минор T4.1: init-script не перезаписывает токен без перезагрузок) | Без перелогина правки после 60-й минуты падали бы по токену | ## Decisions made with the human diff --git a/perf/README.md b/perf/README.md index 270ca8b..c186c40 100644 --- a/perf/README.md +++ b/perf/README.md @@ -86,3 +86,19 @@ cd .. && .venv/bin/python -m perfkit.modals_inventory results/m - Оценка полной матрицы на CPU 1: priority 1 (17 целей x 5) около 6 мин, priority 2 (15 x 3) около 2 мин, profiling около 2 мин; всего 10-12 мин. С `PERF_CPU_RATES=1,4,6` priority 1 вырастет втрое. Малый и большой лист. Форм-модалки (выбор ВСП/статьи, создание программы/проекта) существуют только на листах FORM_2 AHR_RENT/UTILITY/SECURITY (31 строка) и FORM_4 AHR/CAP/OPER (110 строк). Для зависимости от числа строк `perfkit.gen.grow` (`cd perf && .venv/bin/python -m perfkit.gen.grow --scale 1`, идемпотентно, ~4 мин, FORM_4 add_line O(n)) растит FORM_2 AHR_UTILITY/AHR_SECURITY до 500 и FORM_4 CAP до 300 строк и обновляет манифест. В `modals.json` у этих модалок `big_sheet`: пара записей `` (малый лист: AHR_RENT, FORM_4/AHR) и `@big` (UTILITY, FORM_4/CAP). Порядок для run-all: reset -> gen -> скролл -> соавтор -> grow -> модалки; малые замеры от grow не зависят, но скролл по UTILITY/SECURITY/CAP после grow несопоставим с до него. + +### Память + +`perf/e2e/scenarios/memory.spec.ts` - одна SPA-сессия роли `rf` на CPU 1 без перезагрузки страницы, 20 циклов + базовая точка (цикл 0) = 21 запись `scenario: memory`. Цикл: самый большой лист FORM_2 owner=rf (AHR) -> скролл вниз/обратно -> 3 правки ячеек двойным кликом (идут через WS, ждём эхо) -> `/tasks` -> самый большой лист FORM_4 owner=rf (STRUCTURE) -> скролл -> `/tasks` -> gc -> метрики. Навигация - history API (клиентский роутер). Листы FORM_1 у `rf` недоступны, поэтому вместо них FORM_2/FORM_4. + +``` +cd perf/e2e +PERF_RUN_ID=mem npx playwright test scenarios/memory.spec.ts # полный прогон, около часа +PERF_RUN_ID=memsmall PERF_MEM_CYCLES=2 PERF_MEM_SNAPSHOTS=0,2 npx playwright test scenarios/memory.spec.ts # малый +cd .. && .venv/bin/python -m perfkit.memtrend results/mem # наклоны МНК на цикл +``` + +- Метрики записи: `cycle`, `heap_used_mb` (после gc), `documents`, `nodes`, `js_event_listeners` (CDP `Memory.getDOMCounters`), `ws_open` (обёртка над `window.WebSocket`), `ws_created`, `edits_ok`, `cycle_s`, `relogin`. +- Heap snapshots на циклах 0, 10, 20 (`PERF_MEM_SNAPSHOTS`) - `perf/results//artifacts/memory-cycleNN.heapsnapshot`, в git не попадают; открываются в Chrome DevTools (Memory). Разбор retainers - Task 10. +- Переменные: `PERF_MEM_CYCLES` (20), `PERF_MEM_SHEET1/2` (`FORM_x/SHEET/direction|null`), `PERF_TEST_TIMEOUT_MS` (3 ч). Токен (60 мин) перевыпускается по API без перезагрузки, если до конца < 10 мин. +- Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария. diff --git a/perf/e2e/scenarios/memory.spec.ts b/perf/e2e/scenarios/memory.spec.ts new file mode 100644 index 0000000..5898e95 --- /dev/null +++ b/perf/e2e/scenarios/memory.spec.ts @@ -0,0 +1,232 @@ +/** + * Сценарий «память при длительной работе» (Task 8): одна SPA-сессия роли rf без перезагрузки страницы, + * N циклов (PERF_MEM_CYCLES, по умолчанию 20) + базовая точка (цикл 0) = N+1 запись scenario "memory". + * + * Цикл: самый большой лист №1 -> скролл вниз (и обратно) -> 3 правки ячеек через UI -> /tasks -> + * самый большой лист №2 -> скролл -> /tasks -> gc -> метрики. + * Листы: у rf нет листов FORM_1 (они ССП), поэтому берутся самые большие листы owner=rf: №1 - FORM_2 (AHR), + * №2 - FORM_4 (PERF_MEM_SHEET1 / PERF_MEM_SHEET2 - переопределение `//`). + * Навигация - клиентский роутер (history.pushState + popstate), не page.goto. + * Правка: клик по редактируемой числовой ячейке (в приложении редактор открывается кликом), ввод числа, Enter; + * ждём эхо cell_updated is_self=true от сервера по WS. Не удалось - сценарий падает. + * Метрики: cycle, heap_used_mb (после gc), documents, nodes, js_event_listeners (CDP Memory.getDOMCounters), + * ws_open (обёртка над window.WebSocket), edits_ok. Heap snapshots на циклах 0, 10, 20 (artifacts). + * Env: PERF_SCALE, PERF_RUN_ID, PERF_MEM_CYCLES, PERF_MEM_SNAPSHOTS ('0,10,20'), PERF_CPU_RATES (только '1'), + * PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, PERF_TEST_TIMEOUT_MS (3 ч). + */ +import { test, type Page } from '@playwright/test'; +import path from 'node:path'; +import { loadManifest, scale, type Sheet } from '../lib/manifest'; +import { loginAs, WEB, API, ACCESS_KEY, REFRESH_KEY } from '../lib/auth'; +import { throttle, heapUsed, domCounters, heapSnapshot } from '../lib/cdp'; +import { append, runDir } from '../lib/results'; +import { scrollThrough } from '../lib/scroll'; + +const CONTAINER = '.MuiTableContainer-root'; +const MIN_ROWS = 20; +const EDITS_PER_SHEET = 3; +const CYCLES = Number(process.env.PERF_MEM_CYCLES ?? 20); +const SNAP_CYCLES = (process.env.PERF_MEM_SNAPSHOTS ?? '0,10,20').split(',').map((s) => Number(s.trim())); +const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); +const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000); +const TOKEN_MIN_LEFT_S = 600; + +const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; +const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); + +function pickSheet(formType: string, override?: string): Sheet { + const all = loadManifest().sheets.filter((s) => s.owner === 'rf'); + if (override) { + const s = all.find((x) => sheetName(x) === override); + if (!s) throw new Error(`лист ${override} не найден среди owner=rf`); + return s; + } + const best = all.filter((s) => s.form_type === formType).sort((a, b) => b.rows - a.rows)[0]; + if (!best) throw new Error(`нет листов ${formType} с owner=rf`); + return best; +} + +const WS_SCRIPT = `(() => { + if (window.__wsStat) return; + const st = { open: 0, created: 0, closed: 0 }; + const Orig = window.WebSocket; + function Wrapped(url, protocols) { + const ws = protocols === undefined ? new Orig(url) : new Orig(url, protocols); + st.created++; + let counted = false; + ws.addEventListener('open', () => { counted = true; st.open++; }); + ws.addEventListener('close', () => { if (counted) { counted = false; st.open--; st.closed++; } }); + return ws; + } + Wrapped.prototype = Orig.prototype; + for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) Wrapped[k] = Orig[k]; + window.WebSocket = Wrapped; + window.__wsStat = st; +})();`; + +/** Клиентская навигация без перезагрузки: pushState + popstate (BrowserRouter реагирует на popstate). */ +async function spaGo(page: Page, url: string): Promise { + await page.evaluate((u) => { + window.history.pushState({}, '', u); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); + }, url); +} + +async function waitRows(page: Page, what: string): 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(`${what}: rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`); + await page.waitForTimeout(500); + } +} + +async function waitTasks(page: Page): Promise { + await page.waitForFunction(() => location.pathname === '/tasks' && document.querySelector('#root')!.children.length > 0, null, { timeout: 60_000 }); + await page.waitForTimeout(300); +} + +/** Если access-токен скоро истечёт - перелогиниться по API и положить токены в localStorage (без перезагрузки). */ +async function ensureToken(page: Page, user: { username: string; password: string }): Promise { + const left = await page.evaluate((k) => { + try { + const t = localStorage.getItem(k); + if (!t) return -1; + const p = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return p.exp - Date.now() / 1000; + } catch { return -1; } + }, ACCESS_KEY); + if (left > TOKEN_MIN_LEFT_S) return false; + const r = await page.context().request.post(`${API}/api/v1/auth/login`, { data: { username: user.username, password: user.password } }); + if (!r.ok()) throw new Error(`relogin ${user.username}: ${r.status()}`); + const d = await r.json(); + await page.evaluate(([a, rk, at, rt]) => { + localStorage.setItem(a, at); + if (rt) localStorage.setItem(rk, rt); + }, [ACCESS_KEY, REFRESH_KEY, d.access_token, d.refresh_token ?? ''] as const); + return true; +} + +interface WsLog { updates: Array<{ self: boolean; error: boolean }> } + +/** Правка n числовых редактируемых ячеек среди видимых строк: клик, ввод, Enter, ожидание эха. */ +async function editCells(page: Page, wsLog: WsLog, n: number, seed: number, what: string): Promise { + // кандидаты: ячейки с cursor:pointer (редактируемые, не заблокированные), числовой текст + const marks = await page.evaluate(({ sel, need }) => { + const cont = document.querySelector(sel) as HTMLElement; + const cr = cont.getBoundingClientRect(); + const out: Array<{ r: number; c: number; row: string }> = []; + document.querySelectorAll(`${sel} tbody tr`).forEach((tr, ri) => { + const b = tr.getBoundingClientRect(); + if (b.top < cr.top + 80 || b.bottom > cr.bottom - 10) return; + tr.querySelectorAll('div.cell').forEach((el, ci) => { + const e = el as HTMLElement; + if (e.style.cursor !== 'pointer') return; + if (e.querySelector('span div')) return; + const t = (e.textContent ?? '').trim(); + if (!/^-?[\d\s .,]*$/.test(t) || t === '') return; + if (out.filter((o) => o.r === ri).length) return; // не больше одной на строку + out.push({ r: ri, c: ci, row: t }); + }); + }); + return out.slice(0, need); + }, { sel: CONTAINER, need: n }); + if (marks.length < n) throw new Error(`${what}: найдено ${marks.length} редактируемых числовых ячеек из ${n}`); + for (let i = 0; i < marks.length; i++) { + const m = marks[i]; + const cell = page.locator(`${CONTAINER} tbody tr`).nth(m.r).locator('div.cell').nth(m.c); + const before = wsLog.updates.length; + await cell.dblclick(); + const ta = page.locator('textarea').first(); + await ta.waitFor({ state: 'visible', timeout: 10_000 }); + await ta.fill(String(1000 + seed * 10 + i)); + await ta.press('Enter'); + const t0 = Date.now(); + while (wsLog.updates.length === before) { + if (Date.now() - t0 > 15_000) throw new Error(`${what}: нет эха cell_updated за 15 с (правка ${i + 1}/${n})`); + await page.waitForTimeout(50); + } + const u = wsLog.updates.slice(before).find((x) => x.self); + if (!u) throw new Error(`${what}: эхо без is_self (правка ${i + 1})`); + if (u.error) throw new Error(`${what}: сервер отклонил правку (${i + 1}/${n})`); + await ta.waitFor({ state: 'detached', timeout: 10_000 }); // редактор закрыт - правка сохранена + } +} + +async function openAndScroll(page: Page, s: Sheet, what: string): Promise { + await spaGo(page, s.url); + const rows = await waitRows(page, what); + await page.waitForTimeout(300); + const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS); + if (!r.complete) throw new Error(`${what}: скролл не дошёл до конца (${r.reason ?? 'incomplete'})`); + return rows; +} + +test('memory: 20 циклов, rf, CPU 1', async ({ browser }) => { + test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 3600_000)); + const cpu = Number(process.env.PERF_CPU_RATES ?? 1); + if (cpu !== 1) throw new Error('сценарий памяти идёт только на CPU 1 (решение человека)'); + const m = loadManifest(); + const user = m.users['rf']; + const sheet1 = pickSheet('FORM_2', process.env.PERF_MEM_SHEET1); + const sheet2 = pickSheet('FORM_4', process.env.PERF_MEM_SHEET2); + const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); + try { + await loginAs(ctx, user); + await ctx.addInitScript(WS_SCRIPT); + const page = await ctx.newPage(); + const wsLog: WsLog = { updates: [] }; + 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') wsLog.updates.push({ self: d.is_self === true, error: !!d.error }); + } catch { /* не JSON */ } + })); + page.on('pageerror', (e) => console.log('pageerror:', e.message)); + + await page.goto(WEB + '/tasks'); // единственная загрузка страницы за сценарий + await waitTasks(page); + await page.evaluate(() => { (window as any).__sessionMark = 1; }); // пропадёт при перезагрузке страницы + await throttle(page, 1); + + const sample = async (cycle: number, extra: Record) => { + const heap = await heapUsed(page); // gc внутри + const dom = await domCounters(page); + const ws = await page.evaluate(() => (window as any).__wsStat); + const artifacts: string[] = []; + if (SNAP_CYCLES.includes(cycle)) { + const f = path.join(runDir(), 'artifacts', `memory-cycle${String(cycle).padStart(2, '0')}.heapsnapshot`); + await heapSnapshot(page, f); + artifacts.push(path.relative(runDir(), f)); + } + append({ + scenario: 'memory', target: `${safe(sheetName(sheet1))}+${safe(sheetName(sheet2))}`, role: 'rf', scale: scale(), cpu_rate: 1, repeat: 0, + metrics: { + cycle, heap_used_mb: heap / 1048576, documents: dom.documents, nodes: dom.nodes, js_event_listeners: dom.jsEventListeners, + ws_open: ws.open, ws_created: ws.created, ...extra, + }, + artifacts, + }); + console.log(`cycle ${cycle}: heap ${(heap / 1048576).toFixed(1)} MB, nodes ${dom.nodes}, listeners ${dom.jsEventListeners}, ws_open ${ws.open}`); + }; + + await sample(0, {}); + for (let cycle = 1; cycle <= CYCLES; cycle++) { + const t0 = Date.now(); + const relogin = await ensureToken(page, user); + await openAndScroll(page, sheet1, `цикл ${cycle} ${sheetName(sheet1)}`); + await editCells(page, wsLog, EDITS_PER_SHEET, cycle, `цикл ${cycle} ${sheetName(sheet1)}`); + await spaGo(page, '/tasks'); await waitTasks(page); + await openAndScroll(page, sheet2, `цикл ${cycle} ${sheetName(sheet2)}`); + await spaGo(page, '/tasks'); await waitTasks(page); + await page.waitForTimeout(500); // дать закрыться WS и отработать размонтированию + if (!(await page.evaluate(() => (window as any).__sessionMark === 1))) throw new Error('страница перезагружалась: сценарий требует одну SPA-сессию'); + await sample(cycle, { edits_ok: EDITS_PER_SHEET, relogin: relogin ? 1 : 0, cycle_s: (Date.now() - t0) / 1000 }); + } + } finally { + await ctx.close(); + } +}); diff --git a/perf/perfkit/memtrend.py b/perf/perfkit/memtrend.py new file mode 100644 index 0000000..7fc053e --- /dev/null +++ b/perf/perfkit/memtrend.py @@ -0,0 +1,54 @@ +"""Тренд памяти по записям сценария memory (Task 8). + +CLI: python -m perfkit.memtrend +Читает /memory.jsonl, сортирует записи по metrics.cycle и печатает наклон МНК +(единиц метрики на цикл) для heap_used_mb, nodes, js_event_listeners. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +METRICS = ("heap_used_mb", "nodes", "js_event_listeners") + + +def slope_per_cycle(values: list[float]) -> float: + """Наклон прямой МНК по точкам (0, v0), (1, v1), ... Меньше двух точек - ValueError.""" + n = len(values) + if n < 2: + raise ValueError("нужно минимум две точки") + mx = (n - 1) / 2 + my = sum(values) / n + sxx = sum((i - mx) ** 2 for i in range(n)) + sxy = sum((i - mx) * (v - my) for i, v in enumerate(values)) + return sxy / sxx + + +def load_memory(run_dir) -> list[dict]: + f = Path(run_dir) / "memory.jsonl" + recs = [] + for line in f.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + r = json.loads(line) + if r.get("scenario") == "memory": + recs.append(r) + return sorted(recs, key=lambda r: r["metrics"]["cycle"]) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) != 1: + print("usage: python -m perfkit.memtrend ", file=sys.stderr) + return 2 + recs = load_memory(argv[0]) + print(f"циклов (записей): {len(recs)}") + for m in METRICS: + vals = [float(r["metrics"][m]) for r in recs] + print(f"{m}: {slope_per_cycle(vals):.3f} на цикл (первый {vals[0]:.1f}, последний {vals[-1]:.1f})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/perf/tests/test_memtrend.py b/perf/tests/test_memtrend.py new file mode 100644 index 0000000..857d370 --- /dev/null +++ b/perf/tests/test_memtrend.py @@ -0,0 +1,38 @@ +import json +import pytest + +from perfkit.memtrend import slope_per_cycle, main + + +def test_slope_linear(): + assert slope_per_cycle([10, 11, 12, 13]) == pytest.approx(1.0) + + +def test_slope_flat(): + assert slope_per_cycle([5, 5, 5]) == 0.0 + + +def test_slope_noisy_is_least_squares(): + # y = 2x + шум; МНК, а не (last-first)/n + assert slope_per_cycle([0, 3, 3, 7, 8]) == pytest.approx(2.0) + + +def test_slope_needs_two_points(): + with pytest.raises(ValueError): + slope_per_cycle([1.0]) + with pytest.raises(ValueError): + slope_per_cycle([]) + + +def test_cli_prints_three_slopes(tmp_path, capsys): + rows = [ + {"scenario": "memory", "repeat": 0, "metrics": {"cycle": c, "heap_used_mb": 100 + 2 * c, "nodes": 1000 + 5 * c, "js_event_listeners": 50}} + for c in range(4) + ] + # записи могут идти не по порядку и вперемешку с чужим сценарием + lines = [json.dumps(r) for r in reversed(rows)] + [json.dumps({"scenario": "other", "metrics": {"cycle": 0}})] + (tmp_path / "memory.jsonl").write_text("\n".join(lines) + "\n") + assert main([str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "heap_used_mb" in out and "nodes" in out and "js_event_listeners" in out + assert "2.000" in out and "5.000" in out and "0.000" in out