test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс

This commit is contained in:
“Naeel”
2026-08-26 17:03:45 +03:00
parent 3b259cf160
commit b7d60e1d93
7 changed files with 269 additions and 0 deletions
+30
View File
@@ -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
```
+5
View File
@@ -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: долгие нагрузочные/стресс-тесты")
+10
View File
@@ -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
"""
+68
View File
@@ -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()
+52
View File
@@ -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}с")
+37
View File
@@ -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}")
+67
View File
@@ -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}с"
)