test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс
This commit is contained in:
@@ -41,3 +41,33 @@ pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/pyth
|
|||||||
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
|
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
|
||||||
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
|
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
|
||||||
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
|
- 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -35,3 +35,8 @@ def db(tmp_path, monkeypatch):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
conn._local.conn = None
|
conn._local.conn = None
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
@@ -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}с")
|
||||||
@@ -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}")
|
||||||
@@ -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}с"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user