#!/usr/bin/env python3 """ST-COUNT-01 — carga concurrente hacia count-api (vía gateway). Equivalente práctico al k6 k6_count_batches.js para hosts sin k6 instalado. Perfiles: shared8g → ~1 req/s durante 60s, concurrencia máxima 3 (conservador) lab → ~3 req/s durante 120s, concurrencia máxima 8 Usa PDFs reales de docs/plan_pruebas/data/ (rota entre ellos). Ejemplos: python docs/plan_pruebas/scripts/st_count_01_bench.py python docs/plan_pruebas/scripts/st_count_01_bench.py --profile shared8g PROFILE=lab python docs/plan_pruebas/scripts/st_count_01_bench.py --duration 90 """ from __future__ import annotations import argparse import csv import json import os import statistics import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date, datetime, timezone from pathlib import Path try: import requests except ImportError: print("Requiere 'requests': pip install requests", file=sys.stderr) sys.exit(1) ROOT = Path(__file__).resolve().parents[3] DEFAULT_DATA = ROOT / "docs" / "plan_pruebas" / "data" DEFAULT_INFORMES = ROOT / "docs" / "plan_pruebas" / "informes" PROFILES = { "shared8g": {"rate": 1.0, "duration_s": 60, "max_workers": 3}, "lab": {"rate": 3.0, "duration_s": 120, "max_workers": 8}, } def list_pdfs(data_dir: Path) -> list[Path]: if not data_dir.is_dir(): raise SystemExit(f"No existe DATA_DIR={data_dir}") pdfs = sorted({p.resolve() for p in list(data_dir.glob("*.pdf")) + list(data_dir.glob("*.PDF"))}) if not pdfs: raise SystemExit(f"No hay PDFs en {data_dir}") return pdfs def percentile(vals: list[float], p: float) -> float: if not vals: return float("nan") s = sorted(vals) if len(s) == 1: return s[0] k = (len(s) - 1) * (p / 100.0) f = int(k) c = min(f + 1, len(s) - 1) if f == c: return s[f] return s[f] + (s[c] - s[f]) * (k - f) def count_one(base_url: str, pdf: Path, timeout_s: float) -> dict: t0 = time.perf_counter() status = "error" http_code = 0 service_count = "" err = "" try: with pdf.open("rb") as fh: r = requests.post( f"{base_url}/count/v1/count", files={"files": (pdf.name, fh, "application/pdf")}, timeout=timeout_s, ) http_code = r.status_code try: body = r.json() except Exception: body = {} if r.status_code >= 500: status = f"http_{r.status_code}" err = (r.text or "")[:200].replace("\n", " ") elif r.status_code >= 400: status = f"http_{r.status_code}" err = str(body.get("error") or r.text)[:200] else: results = body.get("results") or [] if results: service_count = results[0].get("service_count", "") status = str(results[0].get("status") or "ok") else: status = "ok_empty" except requests.Timeout: status = "timeout" except Exception as ex: status = "exception" err = str(ex)[:200] return { "file": pdf.name, "wall_s": round(time.perf_counter() - t0, 3), "http_code": http_code, "status": status, "service_count": service_count, "error": err, } def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="ST-COUNT-01 count-api load bench") p.add_argument("--base-url", default=os.environ.get("BASE_URL", "http://127.0.0.1:8189")) p.add_argument("--data-dir", type=Path, default=Path(os.environ.get("COUNT_BENCH_PDF_DIR") or DEFAULT_DATA)) p.add_argument( "--profile", choices=sorted(PROFILES.keys()), default=os.environ.get("PROFILE") or "shared8g", ) p.add_argument("--duration", type=float, default=float(os.environ.get("COUNT_BENCH_DURATION_S") or "0")) p.add_argument("--rate", type=float, default=float(os.environ.get("COUNT_BENCH_RATE") or "0")) p.add_argument("--max-workers", type=int, default=int(os.environ.get("COUNT_BENCH_MAX_WORKERS") or "0")) p.add_argument("--timeout", type=float, default=float(os.environ.get("COUNT_BENCH_TIMEOUT_S") or "120")) p.add_argument( "--out-csv", type=Path, default=Path( os.environ.get("COUNT_BENCH_CSV") or (DEFAULT_INFORMES / f"st_count_01_{date.today().strftime('%Y%m%d')}.csv") ), ) return p.parse_args() def main() -> int: args = parse_args() base_url = str(args.base_url).rstrip("/") cfg = PROFILES[args.profile] rate = args.rate if args.rate > 0 else cfg["rate"] duration_s = args.duration if args.duration > 0 else cfg["duration_s"] max_workers = args.max_workers if args.max_workers > 0 else cfg["max_workers"] interval = 1.0 / rate if rate > 0 else 1.0 try: h = requests.get(f"{base_url}/health/live", timeout=5) print(f"health/live -> {h.status_code}") except Exception as ex: print(f"AVISO: health/live ({ex})", file=sys.stderr) pdfs = list_pdfs(args.data_dir) print(f"BASE_URL={base_url}") print(f"PROFILE={args.profile} rate={rate}/s duration={duration_s}s max_workers={max_workers}") print(f"DATA_DIR={args.data_dir} ({len(pdfs)} PDFs)") print(f"OUT={args.out_csv}") print() rows: list[dict] = [] lock = threading.Lock() idx = {"i": 0} stop_at = time.perf_counter() + duration_s next_slot = time.perf_counter() def submit_one(ex: ThreadPoolExecutor, futures: set): nonlocal next_slot now = time.perf_counter() if now < next_slot: time.sleep(next_slot - now) next_slot = max(next_slot + interval, time.perf_counter()) with lock: pdf = pdfs[idx["i"] % len(pdfs)] idx["i"] += 1 n = idx["i"] print(f"[launch {n}] {pdf.name} ...", flush=True) futures.add(ex.submit(count_one, base_url, pdf, args.timeout)) args.out_csv.parent.mkdir(parents=True, exist_ok=True) fieldnames = ["file", "wall_s", "http_code", "status", "service_count", "error"] t0 = time.perf_counter() futures: set = set() with ThreadPoolExecutor(max_workers=max_workers) as ex: while time.perf_counter() < stop_at: # No saturar más allá de max_workers en vuelo while len(futures) >= max_workers: done = {f for f in futures if f.done()} for f in done: row = f.result() rows.append(row) print( f" done wall_s={row['wall_s']} code={row['http_code']} status={row['status']}", flush=True, ) futures.remove(f) if len(futures) >= max_workers: time.sleep(0.05) if time.perf_counter() >= stop_at: break submit_one(ex, futures) for f in as_completed(futures): row = f.result() rows.append(row) print( f" done wall_s={row['wall_s']} code={row['http_code']} status={row['status']}", flush=True, ) wall_total = round(time.perf_counter() - t0, 3) with args.out_csv.open("w", encoding="utf-8", newline="") as fh: w = csv.DictWriter(fh, fieldnames=fieldnames) w.writeheader() for row in rows: w.writerow(row) ok = [r for r in rows if r["http_code"] and 200 <= int(r["http_code"]) < 300] fail = [r for r in rows if r not in ok] times = [float(r["wall_s"]) for r in ok] meta = { "scenario": "ST-COUNT-01", "profile": args.profile, "rate_per_s": rate, "duration_s_planned": duration_s, "max_workers": max_workers, "n_total": len(rows), "n_ok": len(ok), "n_fail": len(fail), "wall_total_s": wall_total, "achieved_rate_per_s": round(len(rows) / wall_total, 3) if wall_total else 0, "csv": str(args.out_csv), "finished_at": datetime.now(timezone.utc).isoformat(), } if times: meta["wall_s_avg"] = round(statistics.mean(times), 3) meta["wall_s_p50"] = round(percentile(times, 50), 3) meta["wall_s_p95"] = round(percentile(times, 95), 3) meta["wall_s_min"] = round(min(times), 3) meta["wall_s_max"] = round(max(times), 3) # Throughput efectivo si se procesara en serie con p50 (referencia conservadora) meta["throughput_serial_p50_per_h"] = round(3600 / meta["wall_s_p50"], 1) if meta["wall_s_p50"] else None meta["throughput_parallel_est_per_h"] = ( round(meta["throughput_serial_p50_per_h"] * max_workers, 1) if meta.get("throughput_serial_p50_per_h") else None ) meta_path = args.out_csv.with_suffix(".summary.json") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print() print("=== Resumen ST-COUNT-01 (count-api carga) ===") print(f"CSV: {args.out_csv}") print(f"Summary: {meta_path}") print(f"ok={len(ok)} fail={len(fail)} total={len(rows)} wall_total={wall_total}s") print(f"achieved_rate ~ {meta['achieved_rate_per_s']}/s (plan {rate}/s)") if times: print( f"wall_s avg={meta['wall_s_avg']:.3f} p50={meta['wall_s_p50']:.3f} " f"p95={meta['wall_s_p95']:.3f} min={meta['wall_s_min']:.3f} max={meta['wall_s_max']:.3f}" ) print( f"Throughput ref. serie(p50) ~ {meta['throughput_serial_p50_per_h']} fact/h; " f"x{max_workers} workers ~ {meta['throughput_parallel_est_per_h']} fact/h (ideal)" ) oom_hint = any("timeout" in r["status"] or str(r["http_code"]).startswith("5") for r in rows) if oom_hint: print("AVISO: timeouts/5xx — posible contencion RAM/CPU; revisa docker stats.", file=sys.stderr) # PASS si hay ok y tasa de fallo < 15% (umbral checks k6 rate>0.85) fail_rate = len(fail) / max(len(rows), 1) return 0 if len(ok) > 0 and fail_rate <= 0.15 else 1 if __name__ == "__main__": sys.exit(main())