perf: сценарий памяти

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:17:23 +04:00
parent 6a2439239d
commit fa5dff8c6a
5 changed files with 343 additions and 0 deletions

View File

@ -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

View File

@ -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`: пара записей `<id>` (малый лист: AHR_RENT, FORM_4/AHR) и `<id>@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/<run_id>/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 мин.
- Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария.

View File

@ -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 - переопределение `<form_type>/<sheet>/<direction|null>`).
* Навигация - клиентский роутер (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<void> {
await page.evaluate((u) => {
window.history.pushState({}, '', u);
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
}, url);
}
async function waitRows(page: Page, what: string): 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(`${what}: rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`);
await page.waitForTimeout(500);
}
}
async function waitTasks(page: Page): Promise<void> {
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<boolean> {
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<void> {
// кандидаты: ячейки с 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<number> {
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<string, number>) => {
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();
}
});

54
perf/perfkit/memtrend.py Normal file
View File

@ -0,0 +1,54 @@
"""Тренд памяти по записям сценария memory (Task 8).
CLI: python -m perfkit.memtrend <run_dir>
Читает <run_dir>/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 <run_dir>", 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())

View File

@ -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