diff --git a/tests/README.md b/tests/README.md index 21ee2ef..a202c95 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,3 +41,33 @@ pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/pyth - Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db` (monkeypatch `DB_PATH` → `tmp_path`, `init_db()`). - LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется. + +## Нагрузочные тесты (`tests/load/`, маркер `load`) + +Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно: + +```bash +pytest -m load -q # только нагрузочные +pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных +``` + +| Файл | Что нагружает | +|---|---| +| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе | +| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` | +| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных | +| `load/stress_http.py` | standalone HTTP-стресс (`/upload`, `/health`) — параллельные запросы, RPS/ошибки/таймауты | + +Масштаб через переменные окружения (дефолты — большие): + +```bash +LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \ +LOAD_OPS=5000 LOAD_THREADS=16 LOAD_PER=200 \ +pytest -m load -q +``` + +HTTP-стресс против локального/прод сервиса: + +```bash +python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000 +``` diff --git a/tests/conftest.py b/tests/conftest.py index 4a2ca46..9c37d22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,3 +35,8 @@ def db(tmp_path, monkeypatch): except Exception: pass conn._local.conn = None + + +def pytest_configure(config): + config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты") + diff --git a/tests/load/__init__.py b/tests/load/__init__.py new file mode 100644 index 0000000..61d7cf8 --- /dev/null +++ b/tests/load/__init__.py @@ -0,0 +1,10 @@ +"""Нагрузочные/стресс-тесты contracts-flask. + +Запуск только нагрузочных: + pytest -m load -q +Запуск быстрых (юнит) без нагрузочных: + pytest -m "not load" -q + +Масштаб настраивается переменными окружения (дефолты — большие): + LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER +""" diff --git a/tests/load/stress_http.py b/tests/load/stress_http.py new file mode 100644 index 0000000..0ea0271 --- /dev/null +++ b/tests/load/stress_http.py @@ -0,0 +1,68 @@ +"""HTTP-стресс против contracts-flask (standalone, не pytest). + +Создаёт параллельную нагрузку на /upload большими файлами + /health, +замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить +деградацию/обрывы (шлюз, OOM, SQLite). + +Запуск: + python tests/load/stress_http.py --url http://127.0.0.1:5000 \ + --n 1000 --threads 32 --size 100000 + +Пример против прода (осторожно): + python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \ + --n 200 --threads 16 --size 500000 +""" +import argparse +import time +from concurrent.futures import ThreadPoolExecutor + +import httpx + + +def main(): + ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask") + ap.add_argument("--url", default="http://127.0.0.1:5000") + ap.add_argument("--n", type=int, default=1000, help="число запросов") + ap.add_argument("--threads", type=int, default=32, help="параллельных потоков") + ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах") + ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов") + args = ap.parse_args() + + payload = b"x" * args.size + ok = 0 + err = 0 + slow = 0 + t0 = time.time() + + def one(_): + nonlocal ok, err, slow + try: + with httpx.Client(timeout=30) as c: + if args.health_only: + r = c.get(f"{args.url}/health") + else: + r = c.post( + f"{args.url}/upload", + files={"files": ("load.docx", payload, "application/octet-stream")}, + ) + if r.status_code == 200: + ok += 1 + else: + err += 1 + except httpx.TimeoutException: + err += 1 + slow += 1 + except Exception: + err += 1 + + with ThreadPoolExecutor(max_workers=args.threads) as ex: + list(ex.map(one, range(args.n))) + + elapsed = time.time() - t0 + print(f"url={args.url}") + print(f"n={args.n} threads={args.threads} size={args.size}B health_only={args.health_only}") + print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}") + + +if __name__ == "__main__": + main() diff --git a/tests/load/test_load_apply_ops.py b/tests/load/test_load_apply_ops.py new file mode 100644 index 0000000..af3b78c --- /dev/null +++ b/tests/load/test_load_apply_ops.py @@ -0,0 +1,52 @@ +"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE). + +Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время, +проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано). +""" +import os +import time + +import pytest + +from db import spec_events, spec_current +from db.connection import query + +pytestmark = pytest.mark.load + +N = int(os.getenv("LOAD_OPS", "5000")) + +CID = "load-cid-111" +SID = "load-sid-111" +DID = "load-did-111" + + +def test_mass_apply_ops(db): + # ADD N строк + ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)] + t0 = time.time() + s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {}) + t_add = time.time() - t0 + assert s["added"] == N + assert len(spec_current.list_by_contract(CID)) == N + + # UPDATE всех N строк + hashes = [spec_events._hash(f"услуга {i}") for i in range(N)] + ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)] + t0 = time.time() + s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {}) + t_upd = time.time() - t0 + assert s["updated"] == N + assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена + + # DELETE всех N строк + dels = [{"action": "DELETE", "target_hash": h} for h in hashes] + t0 = time.time() + s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {}) + t_del = time.time() - t0 + assert s["deleted"] == N + assert spec_current.list_by_contract(CID) == [] + + total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,)) + assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно + + print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с") diff --git a/tests/load/test_load_concurrency.py b/tests/load/test_load_concurrency.py new file mode 100644 index 0000000..194943a --- /dev/null +++ b/tests/load/test_load_concurrency.py @@ -0,0 +1,37 @@ +"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout). + +N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная +запись не теряет данные (WAL сериализует writers, busy_timeout ждёт). +""" +import os + +import pytest +from concurrent.futures import ThreadPoolExecutor + +from db import spec_events, spec_current + +pytestmark = pytest.mark.load + +N_THREADS = int(os.getenv("LOAD_THREADS", "16")) +PER = int(os.getenv("LOAD_PER", "200")) + + +def test_concurrent_writes(db): + def writer(tid): + cid = f"c{tid}" + for i in range(PER): + spec_events.apply_ops( + cid, f"s{tid}", f"d{tid}", + [{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}], + "pid", {}, + ) + + with ThreadPoolExecutor(max_workers=N_THREADS) as ex: + list(ex.map(writer, range(N_THREADS))) + + # Никаких потерь: у каждого потока ровно PER строк + for tid in range(N_THREADS): + n = len(spec_current.list_by_contract(f"c{tid}")) + assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)" + + print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}") diff --git a/tests/load/test_load_pipeline.py b/tests/load/test_load_pipeline.py new file mode 100644 index 0000000..5ea0dae --- /dev/null +++ b/tests/load/test_load_pipeline.py @@ -0,0 +1,67 @@ +"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников). + +Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM +(без сети), реально нагружает SQLite: apply_ops + clear_current + reset. + +Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно +SERVICES строк, а не SERVICES × M_SUPPS). +""" +import os +import time + +import pytest + +from db import contracts, documents, supplements, spec_current +from db.connection import query +from services import process + +pytestmark = pytest.mark.load + +N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100")) +M_SUPPS = int(os.getenv("LOAD_SUPPS", "20")) +SERVICES = int(os.getenv("LOAD_SERVICES", "10")) + + +def _seed(): + cids = [] + for ci in range(N_CONTRACTS): + c = contracts.insert(f"03700_{ci}") + for mi in range(M_SUPPS): + d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}") + documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}]) + supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional") + cids.append(c["id"]) + return cids + + +def _fake_llm(): + def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None): + ops = [ + {"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}} + for i in range(SERVICES) + ] + return ({"mode": "full_replace", "ops": ops}, "pid") + return fake_call + + +def test_mass_pipeline(db, monkeypatch): + cids = _seed() + monkeypatch.setattr("services.llm.call_llm", _fake_llm()) + + t0 = time.time() + for cid in cids: + list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid"))) + elapsed = time.time() - t0 + + # Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей) + bad = 0 + for cid in cids: + if len(spec_current.list_by_contract(cid)) != SERVICES: + bad += 1 + assert bad == 0, f"{bad} контрактов с неверным числом строк" + + total_events = query("SELECT count(*) AS c FROM spec_events", ()) + print( + f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} " + f"событий={total_events[0]['c']} время={elapsed:.1f}с" + )