vergabe-teilnahme/scripts/synthetic_pilot.py
tegwick bfd1a2d46d feat: compare synthetic pilot at existing container limits with failure cleanup
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181
2026-10-19 15:15:00 +02:00

690 lines
27 KiB
Python

#!/usr/bin/env python3
"""Provisional loopback HTTP workload using Gunicorn and disposable PostgreSQL."""
import argparse
import concurrent.futures
import hashlib
import http.cookiejar
import json
import math
import os
import platform
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import UTC, datetime
from pathlib import Path
def summarize(rows):
durations = sorted(row["seconds"] for row in rows)
return {
"requests": len(rows),
"errors": sum(not r["ok"] for r in rows),
"p95_seconds": durations[math.ceil(len(durations) * 0.95) - 1] if durations else None,
}
def validate(config):
for key in (
"users",
"iterations",
"tenders",
"document_bytes",
"workers",
"timeout_seconds",
):
if type(config[key]) is not int or config[key] < 1:
raise ValueError(f"{key} must be a positive integer")
for key in (
"think_seconds",
"ordinary_p95_seconds",
"duration_seconds",
"idle_seconds",
):
if not isinstance(config[key], (int, float)) or not math.isfinite(config[key]):
raise ValueError(f"{key} must be finite")
if config["think_seconds"] < 0 or config["ordinary_p95_seconds"] <= 0:
raise ValueError("invalid timing parameter")
if config["duration_seconds"] <= 0 or config["idle_seconds"] < 0:
raise ValueError("duration must be positive and idle nonnegative")
if config["document_bytes"] < 8:
raise ValueError("document_bytes must be at least 8")
if config.get("runtime", "host") not in ("host", "container"):
raise ValueError("unsupported runtime")
if config.get("runtime") == "container":
if (
not isinstance(config["app_cpu_limit"], (int, float))
or not 0 < config["app_cpu_limit"] <= 8
):
raise ValueError("app_cpu_limit must be positive and at most 8")
if not config.get("app_image") or not config.get("app_memory_limit"):
raise ValueError("container image and memory limit required")
return config
def stop_process(server):
"""Bounded teardown of a process group, including Gunicorn workers."""
if server is None:
return
try:
os.killpg(server.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(server.pid, signal.SIGKILL)
except ProcessLookupError:
pass
server.wait(timeout=5)
def guarded_sampler(function, errors):
try:
function()
except Exception as exc:
errors.append({"sampler": function.__name__, "error_type": type(exc).__name__})
def sampling_gaps(samples, db_samples, errors):
gaps = list(errors)
if any(s.get("sample_error") for s in samples):
gaps.append({"sampler": "application", "reason": "sample failed"})
good = [s for s in samples if "monotonic_seconds" in s]
if len(good) < 2 or not db_samples:
gaps.append({"sampler": "coverage", "reason": "insufficient samples"})
if any(b["monotonic_seconds"] - a["monotonic_seconds"] > 3 for a, b in zip(good, good[1:])):
gaps.append({"sampler": "application", "reason": "sampling interval exceeds 3 seconds"})
return gaps
def run(config, root):
# Never consume a deployment database, media tree, session or credential.
os.environ["DATABASE_URL"] = os.environ["SYNTHETIC_DATABASE_URL"]
os.environ["SECRET_KEY"] = "synthetic-disposable-not-a-deployment-key"
os.environ["DJANGO_SETTINGS_MODULE"] = "synthetic_settings"
container_mode = config.get("runtime", "host") == "container"
media = "/tmp/synthetic-media" if container_mode else str(root / "media")
issue_db = "/tmp/synthetic-issues.db" if container_mode else str(root / "issues.db")
(root / "synthetic_settings.py").write_text(
"from vergabe_teilnahme.settings.base import *\n"
'DEBUG = False\nALLOWED_HOSTS = ["127.0.0.1"]\nNETKINGDOM_ENABLED = False\n'
f"MEDIA_ROOT = {media!r}\n"
f"ISSUE_FACADE_LOCAL_DB = {issue_db!r}\n"
'ISSUE_FACADE_GITEA = None\nFORCE_SCRIPT_NAME = None\nMEDIA_URL = "/media/"\n'
'EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"\n'
"SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = False\n"
)
sys.path.insert(0, str(root))
import django
django.setup()
from django.core.management import call_command
from django.db import connections
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.dokumente.models import Dokument
call_command("migrate", verbosity=0)
for i in range(config["tenders"]):
Ausschreibung.objects.create(titel=f"Synthetic tender {i}", ausschreiber="Synthetic")
users = []
password = uuid.uuid4().hex
for i in range(config["users"]):
user = Mitarbeiter.objects.create_user(username=f"synthetic-{i}", password=password)
users.append(user.username)
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
base_url = f"http://127.0.0.1:{port}"
env = dict(os.environ, PYTHONPATH=str(root) + os.pathsep + str(Path.cwd()))
server_log = (root / "server.log").open("w")
server = None
app_container = None
sampling_done = threading.Event()
sampler_threads = []
sampler_errors = []
try:
if container_mode:
app_container = os.environ["SYNTHETIC_CONTAINER"] + "-app"
root.chmod(0o755)
subprocess.run(
[
"docker",
"run",
"--detach",
"--pull=never",
"--name",
app_container,
"--network",
os.environ["SYNTHETIC_NETWORK"],
"--publish",
"127.0.0.1::8000",
"--cpus",
str(config["app_cpu_limit"]),
"--memory",
config["app_memory_limit"],
"--pids-limit",
"256",
"--mount",
(
f"type=bind,src={root / 'synthetic_settings.py'},"
"dst=/synthetic_settings.py,readonly"
),
"--env",
"PYTHONPATH=/",
"--env",
"DJANGO_SETTINGS_MODULE=synthetic_settings",
"--env",
"SECRET_KEY=synthetic-disposable-not-a-deployment-key",
"--env",
"DATABASE_URL=postgres://synthetic:synthetic-local-only@db:5432/synthetic",
config["app_image"],
"gunicorn",
"vergabe_teilnahme.wsgi:application",
"--bind",
"0.0.0.0:8000",
"--workers",
str(config["workers"]),
"--timeout",
"30",
],
check=True,
timeout=30,
stdout=subprocess.DEVNULL,
)
port = (
subprocess.check_output(
["docker", "port", app_container, "8000/tcp"], text=True, timeout=10
)
.strip()
.split(":")[-1]
)
base_url = f"http://127.0.0.1:{port}"
else:
server = subprocess.Popen(
[
sys.executable,
"-m",
"gunicorn",
"vergabe_teilnahme.wsgi:application",
"--bind",
f"127.0.0.1:{port}",
"--workers",
str(config["workers"]),
"--timeout",
"30",
],
start_new_session=True,
env=env,
stdout=server_log,
stderr=server_log,
)
for _ in range(100):
try:
with urllib.request.urlopen(base_url + "/health/", timeout=1) as response:
if response.status == 200:
break
except OSError:
if server is not None and server.poll() is not None:
raise RuntimeError("Gunicorn startup failed; see isolated server log")
time.sleep(0.1)
else:
raise RuntimeError("Gunicorn readiness timeout")
samples = []
db_samples = []
phase = {"name": "idle"}
def sample():
ticks_per_second = os.sysconf("SC_CLK_TCK")
while not sampling_done.is_set():
if container_mode:
value = subprocess.check_output(
[
"docker",
"exec",
app_container,
"sh",
"-c",
(
"cat /sys/fs/cgroup/cpu.stat; echo MEMORY; "
"cat /sys/fs/cgroup/memory.current"
),
],
text=True,
timeout=5,
)
cpu_text, memory_text = value.split("MEMORY\n")
counters = {
k: int(v) for k, v in (line.split() for line in cpu_text.splitlines())
}
samples.append(
{
"at": datetime.now(UTC).isoformat(),
"monotonic_seconds": time.monotonic(),
"phase": phase["name"],
"cpu_seconds": counters["usage_usec"] / 1e6,
"rss_bytes": int(memory_text.strip()),
"memory_kind": "cgroup-memory-current",
"cpu_stat": counters,
"pids": [app_container],
}
)
sampling_done.wait(0.5)
continue
pids = [server.pid]
try:
pids += [
int(x)
for x in Path(f"/proc/{server.pid}/task/{server.pid}/children")
.read_text()
.split()
]
cpu, rss = 0, 0
for pid in pids:
fields = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split()
cpu += (int(fields[11]) + int(fields[12])) / ticks_per_second
rss += int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
samples.append(
{
"at": datetime.now(UTC).isoformat(),
"monotonic_seconds": time.monotonic(),
"phase": phase["name"],
"cpu_seconds": cpu,
"rss_bytes": rss,
"pids": pids,
}
)
except (OSError, ValueError):
samples.append({"at": datetime.now(UTC).isoformat(), "sample_error": True})
sampling_done.wait(0.5)
def sample_db():
while not sampling_done.is_set():
value = subprocess.check_output(
[
"docker",
"stats",
"--no-stream",
"--format",
"{{json .}}",
os.environ["SYNTHETIC_CONTAINER"],
],
text=True,
timeout=5,
)
db_samples.append(
{
"at": datetime.now(UTC).isoformat(),
"phase": phase["name"],
"docker_stats": json.loads(value),
}
)
sampling_done.wait(1)
sampler = threading.Thread(
target=guarded_sampler, args=(sample, sampler_errors), daemon=True
)
db_sampler = threading.Thread(
target=guarded_sampler, args=(sample_db, sampler_errors), daemon=True
)
sampler_threads.extend([sampler, db_sampler])
sampler.start()
db_sampler.start()
time.sleep(config["idle_seconds"])
phase["name"] = "ordinary"
deadline = time.monotonic() + config["duration_seconds"]
completed = [0] * config["users"]
barrier = threading.Barrier(config["users"])
def worker(i):
jar = http.cookiejar.CookieJar()
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), NoRedirect()
)
rows = []
def request(operation, method, path, data=None, expected=200, transfer=False):
started = time.perf_counter()
try:
headers = {}
body = None
if method == "post":
token = next((c.value for c in jar if c.name == "csrftoken"), "")
fields = dict(data or {}, csrfmiddlewaretoken=token)
if transfer:
boundary = "synthetic-" + uuid.uuid4().hex
body = b""
for name, value in fields.items():
body += (
f"--{boundary}\r\nContent-Disposition: form-data; "
f'name="{name}"\r\n\r\n{value}\r\n'
).encode()
body += (
(
f"--{boundary}\r\nContent-Disposition: form-data; "
'name="datei"; filename="synthetic.pdf"\r\n'
"Content-Type: application/pdf\r\n\r\n"
).encode()
+ b"%PDF-1.4"
+ b"x" * (config["document_bytes"] - 8)
+ f"\r\n--{boundary}--\r\n".encode()
)
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
else:
body = urllib.parse.urlencode(fields).encode()
headers["Referer"] = base_url + path
req = urllib.request.Request(
base_url + path, data=body, headers=headers, method=method.upper()
)
try:
response = opener.open(req, timeout=config["timeout_seconds"])
except urllib.error.HTTPError as exc:
response = exc
received = len(response.read())
status = response.status
ok = status == expected
response.close()
except Exception:
status, ok, received = None, False, 0
rows.append(
{
"user": i,
"operation": operation,
"transfer": transfer,
"seconds": time.perf_counter() - started,
"status": status,
"ok": ok,
"response_bytes": received,
}
)
return ok
request("login-form", "get", "/accounts/login/")
barrier.wait(timeout=30)
request(
"login",
"post",
"/accounts/login/",
{"username": users[i], "password": password},
302,
)
for n in range(config["iterations"]):
if time.monotonic() >= deadline:
break
request("list", "get", "/ausschreibungen/")
request("search", "get", "/ausschreibungen/?q=Synthetic")
title = f"Workload-{i}-{n}"
created = request(
"tender-create",
"post",
"/ausschreibungen/neu/",
{"titel": title, "ausschreiber": "Synthetic"},
302,
)
tender = Ausschreibung.objects.filter(titel=title).first() if created else None
if tender:
base = f"/ausschreibungen/{tender.pk}/"
request("detail", "get", base)
request(
"lot-create",
"post",
base + "lose/neu/",
{"losnummer": "1", "lostitel": "Synthetic lot"},
302,
)
request(
"task-create",
"post",
base + "aufgaben/neu/",
{"titel": "Synthetic task", "typ": "fachlich", "prioritaet": 2},
302,
)
request(
"document-upload",
"post",
base + "dokumente/hochladen/",
{"kategorie": "intern", "version": "1.0"},
302,
True,
)
document = Dokument.objects.filter(ausschreibung=tender).first()
if document:
request("document-download", "get", document.datei.url, transfer=True)
completed[i] += 1
time.sleep(config["think_seconds"])
connections.close_all()
return rows
started = datetime.now(UTC).isoformat()
before = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=config["users"]) as pool:
rows = [r for group in pool.map(worker, range(config["users"])) for r in group]
ordinary = summarize([r for r in rows if not r["transfer"]])
transfers = summarize([r for r in rows if r["transfer"]])
complete = all(completed) and len(rows) == sum(completed) * 8 + config["users"] * 2
result = {
"schema_version": 1,
"started_at": started,
"finished_at": datetime.now(UTC).isoformat(),
"duration_seconds": time.perf_counter() - before,
"config": config,
"environment": {
"python": platform.python_version(),
"django": django.get_version(),
"platform": platform.platform(),
"database": "disposable PostgreSQL 16 container",
"transport": "loopback HTTP Gunicorn",
"workers": config["workers"],
},
"ordinary": ordinary,
"document_transfer": transfers,
"complete": complete,
"provisional_pass": complete
and ordinary["errors"] == 0
and transfers["errors"] == 0
and ordinary["p95_seconds"] <= config["ordinary_p95_seconds"],
"limitations": [
"No browser, ingress, TLS or SSO",
"No CPU/memory allocation recommendation or human acceptance",
"No timed startup/restart or distinct burst; idle and ordinary phases only",
],
"operations": rows,
"runtime_samples": samples,
"database_samples": db_samples,
"completed_iterations": completed,
}
sampling_done.set()
for thread in sampler_threads:
thread.join(timeout=7)
gaps = sampling_gaps(samples, db_samples, sampler_errors)
if any(thread.is_alive() for thread in sampler_threads):
gaps.append({"sampler": "cleanup", "reason": "thread did not stop"})
result["measurement_gaps"] = gaps
result["measurement_complete"] = not gaps
if container_mode:
result["app_container"] = json.loads(
subprocess.check_output(
["docker", "inspect", "--format", "{{json .}}", app_container],
text=True,
timeout=10,
)
)
# Retain only public runtime identifiers/limits, never full container environment.
info = result.pop("app_container")
result["app_runtime"] = {
"image_id": info["Image"],
"nano_cpus": info["HostConfig"]["NanoCpus"],
"memory_bytes": info["HostConfig"]["Memory"],
"memory_kind": "cgroup-memory-current includes page cache",
"oom_killed": info["State"]["OOMKilled"],
"dockerfile_sha256": hashlib.sha256(Path("Dockerfile").read_bytes()).hexdigest(),
}
return result
finally:
sampling_done.set()
for thread in sampler_threads:
if thread.ident is not None:
thread.join(timeout=7)
stop_process(server)
if app_container is not None:
subprocess.run(
["docker", "rm", "--force", "--volumes", app_container],
timeout=15,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
server_log.close()
connections.close_all()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
config = validate(json.loads(args.config.read_text()))
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
name = "vergabe-synthetic-" + uuid.uuid4().hex[:12]
network = name + "-network"
network_args = []
try:
if config.get("runtime") == "container":
subprocess.run(
["docker", "network", "create", network],
check=True,
timeout=15,
stdout=subprocess.DEVNULL,
)
os.environ["SYNTHETIC_NETWORK"] = network
network_args = ["--network", network, "--network-alias", "db"]
# Cached image only, loopback random host port, no existing volume or database.
subprocess.run(
[
"docker",
"run",
"--detach",
"--pull=never",
"--name",
name,
"--publish",
"127.0.0.1::5432",
"--env",
"POSTGRES_DB=synthetic",
"--env",
"POSTGRES_USER=synthetic",
"--env",
"POSTGRES_PASSWORD=synthetic-local-only",
*network_args,
"postgres:16-alpine",
],
check=True,
stdout=subprocess.DEVNULL,
)
for _ in range(100):
ready = subprocess.run(
["docker", "exec", name, "pg_isready", "-U", "synthetic"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if ready.returncode == 0:
break
time.sleep(0.1)
else:
raise RuntimeError("isolated PostgreSQL startup failed")
port = (
subprocess.check_output(["docker", "port", name, "5432/tcp"], text=True)
.strip()
.split(":")[-1]
)
os.environ["SYNTHETIC_CONTAINER"] = name
os.environ["SYNTHETIC_DATABASE_URL"] = (
f"postgres://synthetic:synthetic-local-only@127.0.0.1:{port}/synthetic"
)
with tempfile.TemporaryDirectory(prefix="vergabe-synthetic-") as directory:
result = run(config, Path(directory))
result["postgres_image_id"] = subprocess.check_output(
["docker", "inspect", "--format", "{{.Image}}", name], text=True
).strip()
result["postgres_final_stats"] = json.loads(
subprocess.check_output(
["docker", "stats", "--no-stream", "--format", "{{json .}}", name],
text=True,
)
)
finally:
subprocess.run(
["docker", "rm", "--force", "--volumes", name],
timeout=15,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if network_args:
subprocess.run(
["docker", "network", "rm", network],
check=True,
timeout=15,
stdout=subprocess.DEVNULL,
)
result["source_revision"] = subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True
).strip()
result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
app_files = sorted(Path("vergabe_teilnahme").rglob("*.py")) + [
Path("uv.lock"),
Path("pyproject.toml"),
]
result["app_source_sha256"] = hashlib.sha256(
b"".join(str(p).encode() + b"\0" + p.read_bytes() for p in app_files)
).hexdigest()
result["host"] = {
"logical_cpus": os.cpu_count(),
"affinity_cpus": len(os.sched_getaffinity(0)),
"cpu_model": next(
(
x.split(":", 1)[1].strip()
for x in Path("/proc/cpuinfo").read_text().splitlines()
if x.startswith("model name")
),
"unknown",
),
"memory_total": next(
x for x in Path("/proc/meminfo").read_text().splitlines() if x.startswith("MemTotal:")
),
"cgroup_cpu_max": Path("/sys/fs/cgroup/cpu.max").read_text().strip()
if Path("/sys/fs/cgroup/cpu.max").exists()
else "unavailable",
"app_cpu_quota": config.get("app_cpu_limit", "not imposed by harness"),
}
result["config_sha256"] = hashlib.sha256(args.config.read_bytes()).hexdigest()
args.output.write_text(json.dumps(result, indent=2) + "\n")
print(
json.dumps(
{
k: result[k]
for k in (
"ordinary",
"document_transfer",
"complete",
"provisional_pass",
)
}
)
)
raise SystemExit(0 if result["provisional_pass"] and result["measurement_complete"] else 1)