668 lines
26 KiB
Python
668 lines
26 KiB
Python
"""M16 bounded soak.
|
|
|
|
A realistic mixed workload held for a bounded period, measuring what actually drifts: latency
|
|
distribution, cold versus warm execution, memory, GPU, database growth, queue depth, leases and
|
|
orphaned work. Every number reported here is measured; nothing is extrapolated from a short run.
|
|
|
|
The workload deliberately mixes bursts, steady state and idle periods. A soak that only hammers
|
|
never exercises the keep-warm and unload paths, and a soak that only idles never exercises
|
|
admission control.
|
|
|
|
Production is read, never mutated: no promotion, no alias change, no external workload is touched.
|
|
The only writes are gateway requests through a disposable LAB client and the operational polls the
|
|
platform already performs for itself.
|
|
|
|
python scripts/m16_soak.py --minutes 45 --report soak.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import random
|
|
import socket
|
|
import statistics
|
|
import subprocess # noqa: S404 - fixed argv container inspection, never a shell
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy import create_engine, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend" / "src"))
|
|
|
|
from modelforge_api.services.invariants import ( # noqa: E402
|
|
InvariantStatus,
|
|
check_invariants,
|
|
)
|
|
|
|
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
|
|
DEFAULT_DATABASE_URL = os.getenv("MODELFORGE_RUNTIME_DATABASE_URL")
|
|
|
|
SENTENCES = [
|
|
"ModelForge binds applications to capabilities rather than to model paths.",
|
|
"A verified backup is the only backup that counts as a recovery point.",
|
|
"Stale telemetry must never be presented as present-day truth.",
|
|
"Every production promotion carries evidence from local validation.",
|
|
"Downloaded model repositories stay untrusted until supply-chain checks complete.",
|
|
"The scheduler refuses unsafe work instead of risking an out-of-memory failure.",
|
|
"Recovery reconciliation clears current truth and re-measures it.",
|
|
"An embedding-space change never silently reuses incompatible vectors.",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class Sample:
|
|
at: float
|
|
kind: str
|
|
ok: bool
|
|
status: int
|
|
latency_ms: float
|
|
detail: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class SoakState:
|
|
samples: list[Sample] = field(default_factory=list)
|
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
stop: threading.Event = field(default_factory=threading.Event)
|
|
|
|
def record(self, sample: Sample) -> None:
|
|
with self.lock:
|
|
self.samples.append(sample)
|
|
|
|
|
|
def docker(*args: str) -> str:
|
|
try:
|
|
completed = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
["docker", *args], # noqa: S607
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return ""
|
|
return (completed.stdout or "").strip()
|
|
|
|
|
|
def container_stats(names: list[str]) -> dict[str, dict[str, Any]]:
|
|
"""Point-in-time RSS and CPU per container, read from the runtime rather than guessed."""
|
|
|
|
raw = docker(
|
|
"stats",
|
|
"--no-stream",
|
|
"--format",
|
|
"{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}",
|
|
*names,
|
|
)
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for line in raw.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) != 4:
|
|
continue
|
|
name, memory, cpu, pids = parts
|
|
used = memory.split("/")[0].strip()
|
|
result[name] = {"memory": used, "cpu_percent": cpu.strip(), "pids": pids.strip()}
|
|
return result
|
|
|
|
|
|
def restart_counts(names: list[str]) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for name in names:
|
|
value = docker("inspect", "--format", "{{.RestartCount}}", name)
|
|
counts[name] = int(value) if value.isdigit() else -1
|
|
return counts
|
|
|
|
|
|
def http(
|
|
url: str,
|
|
*,
|
|
token: str | None = None,
|
|
bearer: str | None = None,
|
|
method: str = "GET",
|
|
payload: dict[str, Any] | None = None,
|
|
timeout: float = 180.0,
|
|
) -> tuple[bool, int, float, dict[str, Any]]:
|
|
body = json.dumps(payload).encode() if payload is not None else None
|
|
request = urllib.request.Request(url, data=body, method=method) # noqa: S310 - fixed scheme
|
|
if token:
|
|
request.add_header("X-ModelForge-Admin-Token", token)
|
|
if bearer:
|
|
request.add_header("Authorization", f"Bearer {bearer}")
|
|
if body is not None:
|
|
request.add_header("Content-Type", "application/json")
|
|
started = time.perf_counter()
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
raw = response.read()
|
|
elapsed = (time.perf_counter() - started) * 1000
|
|
try:
|
|
decoded = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
decoded = {}
|
|
return True, response.status, elapsed, decoded if isinstance(decoded, dict) else {}
|
|
except urllib.error.HTTPError as error:
|
|
elapsed = (time.perf_counter() - started) * 1000
|
|
try:
|
|
decoded = json.loads(error.read().decode("utf-8"))
|
|
except Exception: # noqa: BLE001 - an unreadable error body is still an error
|
|
decoded = {}
|
|
return False, error.code, elapsed, decoded if isinstance(decoded, dict) else {}
|
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
elapsed = (time.perf_counter() - started) * 1000
|
|
return False, 0, elapsed, {"transport_error": type(error).__name__}
|
|
|
|
|
|
# The soak reports latency percentiles, so the client-side floor has to be negligible before any
|
|
# of them mean anything. A first run measured a p50 of two seconds on every route including
|
|
# /health/live; the cause was the harness resolving "localhost" to ::1 on Windows and waiting out
|
|
# the connect timeout before falling back to IPv4. That inflated every percentile by a constant
|
|
# ~2,045 ms of client cost and said nothing about the platform. Measure the floor and refuse rather
|
|
# than publish a number that is mostly the measuring instrument.
|
|
MAX_CONNECT_FLOOR_MS = 100.0
|
|
|
|
|
|
def connect_floor_ms(base_url: str, samples: int = 7) -> float:
|
|
"""Median TCP connect time to the target, excluding anything the platform does."""
|
|
|
|
parsed = urllib.parse.urlparse(base_url)
|
|
host = parsed.hostname or "127.0.0.1"
|
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
timings = []
|
|
for _ in range(samples):
|
|
started = time.perf_counter()
|
|
try:
|
|
connection = socket.create_connection((host, port), timeout=10)
|
|
except OSError:
|
|
return float("inf")
|
|
connection.close()
|
|
timings.append((time.perf_counter() - started) * 1000)
|
|
return statistics.median(timings)
|
|
|
|
|
|
# --------------------------------------------------------------------- workers
|
|
|
|
|
|
def gateway_worker(
|
|
state: SoakState, base_url: str, secret: str, rng: random.Random, worker_id: int
|
|
) -> None:
|
|
"""Real capability traffic with bursts, steady state and idle gaps."""
|
|
|
|
while not state.stop.is_set():
|
|
phase = rng.choices(["burst", "steady", "idle"], weights=[2, 5, 3])[0]
|
|
if phase == "idle":
|
|
state.stop.wait(rng.uniform(8, 25))
|
|
continue
|
|
count = rng.randint(3, 6) if phase == "burst" else 1
|
|
for _ in range(count):
|
|
if state.stop.is_set():
|
|
return
|
|
batch = [rng.choice(SENTENCES) for _ in range(rng.randint(1, 4))]
|
|
ok, status, latency, body = http(
|
|
f"{base_url}/api/v1/capabilities/rag.embedding@1/invoke",
|
|
bearer=secret,
|
|
method="POST",
|
|
payload={"input": batch},
|
|
)
|
|
execution = body.get("execution") or {}
|
|
timings = execution.get("timings") or {}
|
|
state.record(
|
|
Sample(
|
|
at=time.time(),
|
|
kind="gateway.rag.embedding",
|
|
ok=ok,
|
|
status=status,
|
|
latency_ms=latency,
|
|
detail={
|
|
"cold": bool(execution.get("cold")),
|
|
"residency": execution.get("residency"),
|
|
"queue_ms": timings.get("queue_ms"),
|
|
"inference_ms": timings.get("inference_ms"),
|
|
"load_ms": timings.get("load_ms"),
|
|
"worker": worker_id,
|
|
"batch": len(batch),
|
|
"code": (body.get("error") or {}).get("code") if not ok else None,
|
|
},
|
|
)
|
|
)
|
|
if phase == "burst":
|
|
state.stop.wait(rng.uniform(0.05, 0.4))
|
|
else:
|
|
state.stop.wait(rng.uniform(1.5, 5.0))
|
|
|
|
|
|
READ_ROUTES = [
|
|
("registry.models", "/api/v1/models", False),
|
|
("registry.projects", "/api/v1/projects", False),
|
|
("hardware", "/api/v1/hardware", False),
|
|
("health.ready", "/api/v1/health/ready", False),
|
|
("operations.overview", "/api/v1/admin/operations/overview", True),
|
|
("operations.alerts", "/api/v1/admin/operations/alerts?limit=50", True),
|
|
("lifecycle.operations", "/api/v1/admin/lifecycle/operations", True),
|
|
("migration.plans", "/api/v1/admin/migrations/plans", True),
|
|
("recovery.dashboard", "/api/v1/admin/recovery/dashboard", True),
|
|
("recovery.backups", "/api/v1/admin/recovery/backups?limit=20", True),
|
|
]
|
|
|
|
|
|
def read_worker(state: SoakState, base_url: str, token: str, rng: random.Random) -> None:
|
|
while not state.stop.is_set():
|
|
kind, path, needs_token = rng.choice(READ_ROUTES)
|
|
ok, status, latency, _ = http(
|
|
f"{base_url}{path}", token=token if needs_token else None, timeout=60.0
|
|
)
|
|
state.record(Sample(at=time.time(), kind=kind, ok=ok, status=status, latency_ms=latency))
|
|
state.stop.wait(rng.uniform(0.5, 3.0))
|
|
|
|
|
|
def background_worker(state: SoakState, base_url: str, token: str) -> None:
|
|
"""The operational work the platform performs for itself, driven at a bounded rate."""
|
|
|
|
while not state.stop.is_set():
|
|
for kind, path in (
|
|
("ops.capacity_collect", "/api/v1/admin/operations/capacity/collect"),
|
|
("ops.slo_evaluate", "/api/v1/admin/operations/slo-evaluations/run"),
|
|
("ops.alert_evaluate", "/api/v1/admin/operations/alerts/evaluate"),
|
|
):
|
|
if state.stop.is_set():
|
|
return
|
|
ok, status, latency, _ = http(
|
|
f"{base_url}{path}", token=token, method="POST", timeout=120.0
|
|
)
|
|
state.record(
|
|
Sample(at=time.time(), kind=kind, ok=ok, status=status, latency_ms=latency)
|
|
)
|
|
state.stop.wait(60)
|
|
|
|
|
|
# --------------------------------------------------------------------- measurement
|
|
|
|
|
|
def resource_snapshot(database_url: str, containers: list[str]) -> dict[str, Any]:
|
|
engine = create_engine(database_url, pool_pre_ping=True)
|
|
try:
|
|
with Session(engine) as session:
|
|
counts = {
|
|
table: int(
|
|
session.execute(text(f"select count(*) from {table}")).scalar_one() # noqa: S608
|
|
)
|
|
for table in (
|
|
"audit_events",
|
|
"gateway_requests",
|
|
"serving_jobs",
|
|
"serving_gpu_leases",
|
|
"slo_evaluations",
|
|
"capacity_snapshots",
|
|
"alert_history_events",
|
|
"operational_alerts",
|
|
"placement_plans",
|
|
"hardware_inventory_runs",
|
|
)
|
|
}
|
|
active_leases = int(
|
|
session.execute(
|
|
text(
|
|
"select count(*) from serving_gpu_leases "
|
|
"where released_at is null and state in "
|
|
"('pending','granted','active','held')"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
in_flight = int(
|
|
session.execute(
|
|
text(
|
|
"select count(*) from serving_jobs "
|
|
"where status in ('queued','leased','running')"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
residency = int(
|
|
session.execute(
|
|
text(
|
|
"select count(*) from residency_allocations "
|
|
"where state in ('loading','resident','unloading')"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
connections = int(
|
|
session.execute(
|
|
text(
|
|
"select count(*) from pg_stat_activity "
|
|
"where datname = current_database()"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
gpu = session.execute(
|
|
text(
|
|
"select a.name, t.used_vram_bytes, t.free_vram_bytes, "
|
|
"t.gpu_utilization_percent, t.observed_at "
|
|
"from accelerator_telemetry_latest t "
|
|
"join accelerators a on a.id = t.accelerator_id"
|
|
)
|
|
).all()
|
|
scheduler = session.execute(
|
|
text(
|
|
"select pressure_state, recovery_candidate, last_observed_at "
|
|
"from scheduler_accelerator_states"
|
|
)
|
|
).all()
|
|
capacity = session.execute(
|
|
text(
|
|
"select gpu_external_bytes, gpu_schedulable_bytes, gpu_managed_resident_bytes, "
|
|
"pressure_state, observed_at from capacity_snapshots "
|
|
"order by observed_at desc limit 1"
|
|
)
|
|
).first()
|
|
finally:
|
|
engine.dispose()
|
|
return {
|
|
"observed_at": datetime.now(UTC).isoformat(),
|
|
"row_counts": counts,
|
|
"active_leases": active_leases,
|
|
"serving_jobs_in_flight": in_flight,
|
|
"residency_allocations": residency,
|
|
"database_connections": connections,
|
|
"gpu": [
|
|
{
|
|
"name": name,
|
|
"used_vram_bytes": used,
|
|
"free_vram_bytes": free,
|
|
"utilisation_percent": util,
|
|
"observed_at": observed.isoformat() if observed else None,
|
|
}
|
|
for name, used, free, util, observed in gpu
|
|
],
|
|
"scheduler": [
|
|
{
|
|
"pressure_state": pressure,
|
|
"recovery_candidate": bool(candidate),
|
|
"last_observed_at": observed.isoformat() if observed else None,
|
|
}
|
|
for pressure, candidate, observed in scheduler
|
|
],
|
|
"latest_capacity": (
|
|
{
|
|
"gpu_external_bytes": capacity[0],
|
|
"gpu_schedulable_bytes": capacity[1],
|
|
"gpu_managed_resident_bytes": capacity[2],
|
|
"pressure_state": capacity[3],
|
|
"observed_at": capacity[4].isoformat() if capacity[4] else None,
|
|
}
|
|
if capacity
|
|
else None
|
|
),
|
|
"containers": container_stats(containers),
|
|
"restart_counts": restart_counts(containers),
|
|
}
|
|
|
|
|
|
def percentile(values: list[float], fraction: float) -> float | None:
|
|
if not values:
|
|
return None
|
|
ordered = sorted(values)
|
|
index = min(len(ordered) - 1, max(0, round(fraction * (len(ordered) - 1))))
|
|
return round(ordered[index], 3)
|
|
|
|
|
|
def summarise(samples: list[Sample]) -> dict[str, Any]:
|
|
by_kind: dict[str, list[Sample]] = {}
|
|
for sample in samples:
|
|
by_kind.setdefault(sample.kind, []).append(sample)
|
|
|
|
result: dict[str, Any] = {}
|
|
for kind, items in sorted(by_kind.items()):
|
|
latencies = [item.latency_ms for item in items if item.ok]
|
|
statuses = Counter(item.status for item in items)
|
|
failures = [item for item in items if not item.ok]
|
|
capacity = [
|
|
item
|
|
for item in failures
|
|
if item.status in (429, 503) or item.detail.get("code") in ("QUEUE_FULL", "RATE_LIMITED")
|
|
]
|
|
internal = [item for item in failures if item.status >= 500 or item.status == 0]
|
|
entry: dict[str, Any] = {
|
|
"requests": len(items),
|
|
"succeeded": len(items) - len(failures),
|
|
"capacity_rejected": len(capacity),
|
|
"internal_errors": len(internal),
|
|
"statuses": dict(sorted(statuses.items())),
|
|
"p50_ms": percentile(latencies, 0.50),
|
|
"p95_ms": percentile(latencies, 0.95),
|
|
"p99_ms": percentile(latencies, 0.99),
|
|
"mean_ms": round(statistics.fmean(latencies), 3) if latencies else None,
|
|
"max_ms": round(max(latencies), 3) if latencies else None,
|
|
}
|
|
if kind.startswith("gateway."):
|
|
cold = [item for item in items if item.ok and item.detail.get("cold")]
|
|
warm = [item for item in items if item.ok and not item.detail.get("cold")]
|
|
queue = [
|
|
float(item.detail["queue_ms"])
|
|
for item in items
|
|
if item.ok and item.detail.get("queue_ms") is not None
|
|
]
|
|
inference = [
|
|
float(item.detail["inference_ms"])
|
|
for item in items
|
|
if item.ok and item.detail.get("inference_ms") is not None
|
|
]
|
|
entry["cold_starts"] = len(cold)
|
|
entry["warm_invocations"] = len(warm)
|
|
entry["queue_p95_ms"] = percentile(queue, 0.95)
|
|
entry["inference_p50_ms"] = percentile(inference, 0.50)
|
|
entry["inference_p95_ms"] = percentile(inference, 0.95)
|
|
entry["failure_codes"] = dict(
|
|
Counter(
|
|
item.detail.get("code") for item in failures if item.detail.get("code")
|
|
)
|
|
)
|
|
result[kind] = entry
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------- runner
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
|
parser.add_argument("--database-url", default=DEFAULT_DATABASE_URL)
|
|
parser.add_argument("--token", default=None)
|
|
parser.add_argument("--secret", default=None, help="gateway client secret")
|
|
parser.add_argument("--minutes", type=float, default=45.0)
|
|
parser.add_argument("--gateway-workers", type=int, default=3)
|
|
parser.add_argument("--read-workers", type=int, default=2)
|
|
parser.add_argument("--seed", type=int, default=20260827)
|
|
parser.add_argument("--report", default=None)
|
|
parser.add_argument("--progress-seconds", type=float, default=120.0)
|
|
args = parser.parse_args(argv)
|
|
if not args.database_url:
|
|
parser.error(
|
|
"--database-url or MODELFORGE_RUNTIME_DATABASE_URL is required "
|
|
"(use the non-owner runtime role)"
|
|
)
|
|
|
|
root = Path(__file__).resolve().parents[1]
|
|
token = args.token
|
|
if not token and (root / ".env").is_file():
|
|
for line in (root / ".env").read_text("utf-8").splitlines():
|
|
if line.startswith("MODELFORGE_OPERATOR_API_KEY="):
|
|
token = line.split("=", 1)[1].strip()
|
|
if not token:
|
|
parser.error("an operator API key is required")
|
|
if not args.secret:
|
|
parser.error("a gateway client secret is required")
|
|
|
|
containers = [
|
|
"modelforge-api-1",
|
|
"modelforge-postgres-1",
|
|
"modelforge-redis-1",
|
|
"modelforge-web-1",
|
|
]
|
|
|
|
floor = connect_floor_ms(args.base_url)
|
|
print(f"client connect floor: {floor:.1f} ms", flush=True)
|
|
if floor > MAX_CONNECT_FLOOR_MS:
|
|
print(
|
|
f"refusing to soak: the client connect floor is {floor:.1f} ms, above the "
|
|
f"{MAX_CONNECT_FLOOR_MS:.0f} ms budget. Every latency percentile would be dominated "
|
|
"by the harness rather than the platform. Use an address that resolves directly "
|
|
"(127.0.0.1 rather than localhost).",
|
|
flush=True,
|
|
)
|
|
return 2
|
|
|
|
print(f"soak: {args.minutes:.1f} minutes, seed {args.seed}", flush=True)
|
|
before = resource_snapshot(args.database_url, containers)
|
|
engine = create_engine(args.database_url, pool_pre_ping=True)
|
|
with Session(engine) as session:
|
|
baseline = check_invariants(session)
|
|
engine.dispose()
|
|
if baseline.violated:
|
|
print("refusing to soak: invariants are already violated", flush=True)
|
|
return 2
|
|
|
|
state = SoakState()
|
|
threads: list[threading.Thread] = []
|
|
for index in range(args.gateway_workers):
|
|
thread = threading.Thread(
|
|
target=gateway_worker,
|
|
args=(state, args.base_url, args.secret, random.Random(args.seed + index), index),
|
|
daemon=True,
|
|
)
|
|
threads.append(thread)
|
|
for index in range(args.read_workers):
|
|
thread = threading.Thread(
|
|
target=read_worker,
|
|
args=(state, args.base_url, token, random.Random(args.seed + 100 + index)),
|
|
daemon=True,
|
|
)
|
|
threads.append(thread)
|
|
threads.append(
|
|
threading.Thread(
|
|
target=background_worker, args=(state, args.base_url, token), daemon=True
|
|
)
|
|
)
|
|
|
|
started = time.time()
|
|
deadline = started + args.minutes * 60
|
|
for thread in threads:
|
|
thread.start()
|
|
|
|
invariant_checks: list[dict[str, Any]] = []
|
|
progress: list[dict[str, Any]] = []
|
|
try:
|
|
next_progress = started + args.progress_seconds
|
|
while time.time() < deadline:
|
|
time.sleep(2)
|
|
if time.time() >= next_progress:
|
|
next_progress = time.time() + args.progress_seconds
|
|
engine = create_engine(args.database_url, pool_pre_ping=True)
|
|
with Session(engine) as session:
|
|
report = check_invariants(session)
|
|
engine.dispose()
|
|
with state.lock:
|
|
total = len(state.samples)
|
|
gateway = sum(
|
|
1 for item in state.samples if item.kind.startswith("gateway.")
|
|
)
|
|
failed = sum(1 for item in state.samples if not item.ok)
|
|
invariant_checks.append(
|
|
{
|
|
"at": datetime.now(UTC).isoformat(),
|
|
"violated": report.violated,
|
|
"violations": [
|
|
item.key
|
|
for item in report.results
|
|
if item.status is InvariantStatus.VIOLATED
|
|
],
|
|
}
|
|
)
|
|
elapsed = (time.time() - started) / 60
|
|
entry = {
|
|
"minutes": round(elapsed, 1),
|
|
"requests": total,
|
|
"gateway": gateway,
|
|
"failed": failed,
|
|
"invariants_violated": report.violated,
|
|
}
|
|
progress.append(entry)
|
|
print(f" {json.dumps(entry)}", flush=True)
|
|
if report.violated:
|
|
print(" invariant violated; stopping the soak early", flush=True)
|
|
break
|
|
except KeyboardInterrupt:
|
|
print(" interrupted; reporting partial evidence", flush=True)
|
|
finally:
|
|
state.stop.set()
|
|
for thread in threads:
|
|
thread.join(timeout=240)
|
|
|
|
duration = time.time() - started
|
|
after = resource_snapshot(args.database_url, containers)
|
|
engine = create_engine(args.database_url, pool_pre_ping=True)
|
|
with Session(engine) as session:
|
|
final = check_invariants(session)
|
|
engine.dispose()
|
|
|
|
with state.lock:
|
|
samples = list(state.samples)
|
|
|
|
drift = {
|
|
table: after["row_counts"][table] - before["row_counts"][table]
|
|
for table in before["row_counts"]
|
|
}
|
|
report = {
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"seed": args.seed,
|
|
"client_connect_floor_ms": round(floor, 3),
|
|
"requested_minutes": args.minutes,
|
|
"actual_duration_seconds": round(duration, 1),
|
|
"gateway_workers": args.gateway_workers,
|
|
"read_workers": args.read_workers,
|
|
"total_requests": len(samples),
|
|
"total_failures": sum(1 for item in samples if not item.ok),
|
|
"by_kind": summarise(samples),
|
|
"resources_before": before,
|
|
"resources_after": after,
|
|
"row_growth": drift,
|
|
"invariant_checks": invariant_checks,
|
|
"invariants_final": {
|
|
"checked": final.checked,
|
|
"violated": final.violated,
|
|
"violations": [
|
|
{"key": item.key, "examples": item.violations}
|
|
for item in final.results
|
|
if item.status is InvariantStatus.VIOLATED
|
|
],
|
|
},
|
|
"progress": progress,
|
|
}
|
|
|
|
print(json.dumps({key: report[key] for key in ("actual_duration_seconds", "total_requests", "total_failures")}, indent=2))
|
|
print(json.dumps(report["by_kind"], indent=2))
|
|
print("row growth:", json.dumps(drift))
|
|
print("leases after:", after["active_leases"], "| jobs in flight:", after["serving_jobs_in_flight"])
|
|
print("invariants:", final.holding, "/", final.checked, "hold")
|
|
|
|
if args.report:
|
|
Path(args.report).write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
print(f"report written to {args.report}")
|
|
return 0 if final.violated == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|