整理代码并提交
This commit is contained in:
commit
968f37ad94
309 files changed
+46134
No files matched your search
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from app.db.models.base import Base
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BenchResult:
|
||||
total: int
|
||||
ok: int
|
||||
no_match: int
|
||||
other_error: int
|
||||
durations_s: list[float]
|
||||
|
||||
|
||||
def _p(sorted_values: list[float], p: float) -> float:
|
||||
if not sorted_values:
|
||||
return 0.0
|
||||
p = max(0.0, min(float(p), 100.0))
|
||||
if p == 0.0:
|
||||
return sorted_values[0]
|
||||
if p == 100.0:
|
||||
return sorted_values[-1]
|
||||
k = (len(sorted_values) - 1) * (p / 100.0)
|
||||
f = int(k)
|
||||
c = min(f + 1, len(sorted_values) - 1)
|
||||
if f == c:
|
||||
return sorted_values[f]
|
||||
d0 = sorted_values[f] * (c - k)
|
||||
d1 = sorted_values[c] * (k - f)
|
||||
return d0 + d1
|
||||
|
||||
|
||||
async def _migrate_and_seed(*, app, seed_images: int) -> None:
|
||||
engine = app.state.engine
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
seed_images_i = max(1, int(seed_images))
|
||||
values: list[tuple[Any, ...]] = []
|
||||
for i in range(seed_images_i):
|
||||
illust_id = 500_000_000 + i
|
||||
random_key = (i + 0.5) / seed_images_i
|
||||
values.append(
|
||||
(
|
||||
illust_id,
|
||||
0,
|
||||
"jpg",
|
||||
f"https://i.pximg.net/img-original/img/2023/01/01/00/00/00/{illust_id}_p0.jpg",
|
||||
f"/i/seed_{illust_id}.jpg",
|
||||
float(random_key),
|
||||
1000,
|
||||
1000,
|
||||
1.0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
)
|
||||
|
||||
await conn.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO images (
|
||||
illust_id, page_index, ext, original_url, proxy_path, random_key,
|
||||
width, height, aspect_ratio, orientation, x_restrict, ai_type,
|
||||
status, fail_count, created_import_id,
|
||||
last_fail_at, last_ok_at, last_error_code, last_error_msg,
|
||||
user_id, user_name, title, created_at_pixiv,
|
||||
added_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
|
||||
""".strip(),
|
||||
values,
|
||||
)
|
||||
|
||||
|
||||
async def _run_bench(*, app, total_requests: int, concurrency: int, endpoint: str) -> BenchResult:
|
||||
total_i = max(1, int(total_requests))
|
||||
conc_i = max(1, int(concurrency))
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
timeout = httpx.Timeout(30.0, connect=10.0)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://bench.local", timeout=timeout)
|
||||
|
||||
semaphore = asyncio.Semaphore(conc_i)
|
||||
durations_s: list[float] = []
|
||||
ok = 0
|
||||
no_match = 0
|
||||
other_error = 0
|
||||
|
||||
async def one(i: int) -> None:
|
||||
nonlocal ok, no_match, other_error
|
||||
_ = i
|
||||
async with semaphore:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
resp = await client.get(endpoint)
|
||||
if resp.status_code == 200:
|
||||
ok += 1
|
||||
elif resp.status_code == 404:
|
||||
no_match += 1
|
||||
else:
|
||||
other_error += 1
|
||||
except Exception:
|
||||
other_error += 1
|
||||
finally:
|
||||
durations_s.append(time.perf_counter() - started)
|
||||
|
||||
try:
|
||||
tasks = [asyncio.create_task(one(i)) for i in range(total_i)]
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
return BenchResult(
|
||||
total=total_i,
|
||||
ok=ok,
|
||||
no_match=no_match,
|
||||
other_error=other_error,
|
||||
durations_s=durations_s,
|
||||
)
|
||||
|
||||
|
||||
def _build_report(*, args, result: BenchResult, elapsed_s: float) -> dict[str, Any]:
|
||||
ds = sorted(float(x) for x in result.durations_s if x is not None and x >= 0)
|
||||
mean = statistics.fmean(ds) if ds else 0.0
|
||||
p50 = _p(ds, 50.0)
|
||||
p90 = _p(ds, 90.0)
|
||||
p99 = _p(ds, 99.0)
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"endpoint": args.endpoint,
|
||||
"requests": {
|
||||
"total": result.total,
|
||||
"concurrency": args.concurrency,
|
||||
"ok": result.ok,
|
||||
"no_match": result.no_match,
|
||||
"other_error": result.other_error,
|
||||
},
|
||||
"latency_s": {
|
||||
"min": ds[0] if ds else 0.0,
|
||||
"p50": p50,
|
||||
"p90": p90,
|
||||
"p99": p99,
|
||||
"max": ds[-1] if ds else 0.0,
|
||||
"mean": mean,
|
||||
},
|
||||
"throughput": {
|
||||
"elapsed_s": float(elapsed_s),
|
||||
"rps": float(result.total / elapsed_s) if elapsed_s > 0 else 0.0,
|
||||
},
|
||||
"seed_images": int(args.seed_images),
|
||||
"env": {
|
||||
"python": sys.version.split()[0],
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
},
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
async def main_async(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="In-process /random load test using httpx ASGITransport.")
|
||||
parser.add_argument("--requests", type=int, default=500)
|
||||
parser.add_argument("--concurrency", type=int, default=50)
|
||||
parser.add_argument("--seed-images", type=int, default=5000)
|
||||
parser.add_argument("--endpoint", type=str, default="/random?format=json&attempts=1")
|
||||
parser.add_argument("--output", type=str, default="")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="new_pixiv_bench_") as td:
|
||||
db_path = Path(td) / "bench.db"
|
||||
db_url = "sqlite+aiosqlite:///" + db_path.as_posix()
|
||||
|
||||
os.environ["APP_ENV"] = os.environ.get("APP_ENV") or "dev"
|
||||
os.environ["DATABASE_URL"] = db_url
|
||||
os.environ["SECRET_KEY"] = os.environ.get("SECRET_KEY") or "dev-secret-key"
|
||||
os.environ["ADMIN_USERNAME"] = os.environ.get("ADMIN_USERNAME") or "admin"
|
||||
|
||||
app = create_app()
|
||||
try:
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
await _migrate_and_seed(app=app, seed_images=args.seed_images)
|
||||
|
||||
# Warm-up: one request to prime import paths/DB connections.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://bench.local",
|
||||
) as c:
|
||||
await c.get("/healthz")
|
||||
|
||||
started = time.perf_counter()
|
||||
result = await _run_bench(
|
||||
app=app,
|
||||
total_requests=args.requests,
|
||||
concurrency=args.concurrency,
|
||||
endpoint=args.endpoint,
|
||||
)
|
||||
elapsed_s = time.perf_counter() - started
|
||||
|
||||
report = _build_report(args=args, result=result, elapsed_s=elapsed_s)
|
||||
|
||||
out_path = (args.output or "").strip()
|
||||
if out_path:
|
||||
out = Path(out_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[bench_random_asgi] wrote report: {out}")
|
||||
else:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
engine = getattr(app.state, "engine", None)
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
return asyncio.run(main_async(argv))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
Write-Host "== test_all: backend =="
|
||||
& (Join-Path $PSScriptRoot "test_backend.ps1")
|
||||
|
||||
Write-Host "== test_all: frontend =="
|
||||
& (Join-Path $PSScriptRoot "test_frontend.ps1")
|
||||
|
||||
if (Test-Path "deploy\\docker-compose.yml") {
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
throw "docker not found but deploy/docker-compose.yml exists"
|
||||
}
|
||||
Write-Host "== test_all: docker compose config =="
|
||||
docker compose -f deploy/docker-compose.yml config | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "== test_all: OK =="
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not (Test-Path "backend")) {
|
||||
Write-Host "[test_backend] backend/ not found -> skip"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "[test_backend] python compileall"
|
||||
py -3.11 -m compileall -q backend
|
||||
|
||||
if (Test-Path "backend\\tests") {
|
||||
Write-Host "[test_backend] pytest"
|
||||
py -3.11 -m pytest -q backend\\tests
|
||||
} else {
|
||||
Write-Host "[test_backend] backend/tests not found -> skip pytest"
|
||||
}
|
||||
|
||||
Write-Host "[test_backend] OK"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not (Test-Path "frontend\\package.json")) {
|
||||
Write-Host "[test_frontend] frontend/package.json not found -> skip"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||
throw "npm not found but frontend/package.json exists"
|
||||
}
|
||||
|
||||
Write-Host "[test_frontend] npm -C frontend run lint (if present)"
|
||||
try { npm -C frontend run lint } catch { Write-Host "[test_frontend] lint missing or failed: $($_.Exception.Message)"; throw }
|
||||
|
||||
Write-Host "[test_frontend] npm -C frontend run typecheck (if present)"
|
||||
try { npm -C frontend run typecheck } catch { Write-Host "[test_frontend] typecheck missing or failed: $($_.Exception.Message)"; throw }
|
||||
|
||||
Write-Host "[test_frontend] npm -C frontend test (if present)"
|
||||
if (-not $env:VITEST_MAX_THREADS) { $env:VITEST_MAX_THREADS = "1" }
|
||||
if (-not $env:VITEST_MIN_THREADS) { $env:VITEST_MIN_THREADS = "1" }
|
||||
Write-Host "[test_frontend] vitest threads: min=$($env:VITEST_MIN_THREADS) max=$($env:VITEST_MAX_THREADS)"
|
||||
try { npm -C frontend test } catch { Write-Host "[test_frontend] test missing or failed: $($_.Exception.Message)"; throw }
|
||||
|
||||
Write-Host "[test_frontend] npm -C frontend run build (if present)"
|
||||
if (-not $env:VITE_BUILD_MINIFY) { $env:VITE_BUILD_MINIFY = "false" }
|
||||
Write-Host "[test_frontend] vite build minify=$($env:VITE_BUILD_MINIFY)"
|
||||
try { npm -C frontend run build } catch { Write-Host "[test_frontend] build missing or failed: $($_.Exception.Message)"; throw }
|
||||
|
||||
Write-Host "[test_frontend] OK"
|
||||
@@ -0,0 +1,36 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not (Test-Path "requirements-dev.txt")) {
|
||||
throw "requirements-dev.txt not found at repo root"
|
||||
}
|
||||
|
||||
if (-not (Test-Path "backend")) {
|
||||
throw "backend/ not found"
|
||||
}
|
||||
|
||||
$venvRoot = Join-Path $repoRoot ".venv"
|
||||
$venvPath = Join-Path $venvRoot "backend_verify"
|
||||
|
||||
if (Test-Path $venvPath) {
|
||||
Remove-Item -Recurse -Force $venvPath
|
||||
}
|
||||
|
||||
Write-Host "[verify_backend_install] create venv: $venvPath"
|
||||
py -3.11 -m venv $venvPath
|
||||
|
||||
$python = Join-Path $venvPath "Scripts\\python.exe"
|
||||
|
||||
Write-Host "[verify_backend_install] pip install -U pip"
|
||||
& $python -m pip install -U pip
|
||||
|
||||
Write-Host "[verify_backend_install] pip install -r requirements-dev.txt"
|
||||
& $python -m pip install -r requirements-dev.txt
|
||||
|
||||
Write-Host "[verify_backend_install] pytest"
|
||||
& $python -m pytest -q backend\\tests
|
||||
|
||||
Write-Host "[verify_backend_install] OK"
|
||||
|
||||
Reference in new issue
Block a user