From 44e5458b17fe9e1e4c29fd9d18739fb32a09a67a Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 15:36:17 +0200 Subject: [PATCH] feat: configure synthetic operation mix and measure burst restart recovery Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- SCOPE.md | 10 + config/synthetic-pilot-phases.json | 53 +++ .../2026-10-19-synthetic-sizing/README.md | 37 ++ .../phase-restart-failure-driver.py.gz | Bin 0 -> 8865 bytes .../phase-restart-failure.json | 15 + .../phased-comparison.json | 209 ++++++++ .../phased-sizing-run.json.gz | Bin 0 -> 62224 bytes docs/synthetic-pilot.md | 51 +- scripts/compare_synthetic_limits.py | 33 +- scripts/synthetic_pilot.py | 449 ++++++++++++++---- scripts/test_synthetic_pilot.py | 107 +++++ 11 files changed, 862 insertions(+), 102 deletions(-) create mode 100644 config/synthetic-pilot-phases.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure-driver.py.gz create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/phased-comparison.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/phased-sizing-run.json.gz diff --git a/SCOPE.md b/SCOPE.md index 6090795..96c021d 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -209,3 +209,13 @@ and 1Gi memory limits: the same two-user mix passes locally with ordinary p95 of PostgreSQL. This supports retaining those limits for this provisional scenario; it does not validate a 60m reservation under contention or predict human demand. Sampler gaps refuse sizing, and bounded cleanup applies on errors as well as success. + + +The named-operation profile `config/synthetic-pilot-phases.json` adds validated +operation dependencies, configurable ordering/frequency, persisted tender/lot/task +edits and derived request expectations. Startup, idle, ordinary, zero-think-time +burst, quiescent restart/readiness and recovery are now exercised locally at the +existing container limits. All 2971 operations passed in the retained phased run; +no human or production acceptance follows. Exact-release/hardware/shared-contention +follow-up is a live receiving task, RAPPS-WP-0014-T04 in railiance-apps, feeding +CUST-WP-0071-T02 and RESOURCE-WP-0008-T02. diff --git a/config/synthetic-pilot-phases.json b/config/synthetic-pilot-phases.json new file mode 100644 index 0000000..43fb40e --- /dev/null +++ b/config/synthetic-pilot-phases.json @@ -0,0 +1,53 @@ +{ + "users": 2, + "workers": 2, + "iterations": 100000, + "tenders": 100, + "document_bytes": 1048576, + "think_seconds": 0.2, + "timeout_seconds": 30, + "ordinary_p95_seconds": 2.0, + "duration_seconds": 180, + "idle_seconds": 30, + "runtime": "container", + "app_image": "vergabe-synthetic:local-11f7c8a", + "app_cpu_limit": 1.0, + "app_memory_limit": "1g", + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "phases": [ + { + "name": "ordinary", + "kind": "load", + "duration_seconds": 90, + "think_seconds": 0.2 + }, + { + "name": "burst", + "kind": "load", + "duration_seconds": 30, + "think_seconds": 0 + }, + { + "name": "restart", + "kind": "restart" + }, + { + "name": "recovery", + "kind": "load", + "duration_seconds": 60, + "think_seconds": 0.2 + } + ] +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/README.md b/docs/evidence/2026-10-19-synthetic-sizing/README.md index 9502745..b131a37 100644 --- a/docs/evidence/2026-10-19-synthetic-sizing/README.md +++ b/docs/evidence/2026-10-19-synthetic-sizing/README.md @@ -73,3 +73,40 @@ input. `container-preflight-failure.json` retains the failed internal-network se and verified cleanup; the successful rerun uses a dedicated bridge with only loopback published ports, not a claimed egress firewall. Maintained source includes bounded failure cleanup and explicit sampler-gap refusal; ten focused tests pass. + + +## Named mix, edits and lifecycle follow-up + +The phased profile keeps the same image and1000m/1Gi limits and adds persisted +field checks for tender,lot and task edits. Expected counts derive from its11 +named operations and completed cycles; no fixed8-operation assumption remains. +All2971 requests completed with zero errors and no unexpected measurement gaps. + +| Phase | Total requests | Ordinary p95 | Transfer p95 | +| --- | ---: | ---: | ---: | +| Ordinary (90.902s) | 1533 | 0.255993s | 0.131370s | +| Zero-think burst (31.090s) | 554 | 0.303368s | 0.108940s | +| Recovery (61.304s) | 884 | 0.336486s | 0.133260s | + +Application launch-to-readiness was7.266s; quiescent restart-to-readiness7.128s. +Fixture/database preparation is outside the startup timer. App resource collection +begins once the loopback binding exists; planned restart unavailability is explicit, +not continuous resource evidence.343 app/111 database samples are retained. +Same-phase CPU intervals yield mean936.79m;1376/1813 periods throttled. Peak cgroup +memory.current309.80MiB includes page cache. Separate database CPU snapshotp95 +was74.1% of one core. No production limit or reservation change is recommended +from these additional local results; target hardware/shared contention remain T04. + +The first phased attempt failed at planned restart: Docker reassigned its random +published port and the initial driver checked the old endpoint. A separate disposable +probe reproduced32775→32776. This failure and exact original source snapshot are +retained in `phase-restart-failure.json` / `phase-restart-failure-driver.py.gz`; +partial metrics were not retained by that first driver and are not claimed as +successful phase evidence. The corrected run records32778→32779, checks the new +loopback endpoint, and now writes partial evidence before cleanup on runtime errors. + +`phased-sizing-run.json.gz` preserves the successful raw receipt and exact driver +hash; `phased-comparison.json` is reproducible with the maintained comparator. +It excludes cross-phase CPU deltas/counter resets.17 focused tests, Ruff and diff +checks pass. Previous unbounded/capped evidence remains unchanged. Receiving owner +task RAPPS-WP-0014-T04 is separate from T03's human/recovery acceptance gate. diff --git a/docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure-driver.py.gz b/docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure-driver.py.gz new file mode 100644 index 0000000000000000000000000000000000000000..4038a079154f5a6d8e7c6a7b69e64c71f97b5f3f GIT binary patch literal 8865 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 literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure.json b/docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure.json new file mode 100644 index 0000000..e1b8a9f --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/phase-restart-failure.json @@ -0,0 +1,15 @@ +{ + "status": "failed-at-planned-restart", + "error_type": "RuntimeError", + "reason": "readiness timeout at stale loopback endpoint after Docker restart", + "workload_phase_success": "not asserted; initial driver did not persist partial phase metrics", + "driver_sha256": "983ce1d04fcb4cabec1af63cef7d45757752624274347a3df27280d879c972ac", + "diagnostic_probe": { + "runtime": "disposable postgres port-binding check", + "before": "127.0.0.1:32775", + "after": "127.0.0.1:32776", + "changed": true + }, + "correction": "re-read and validate loopback binding after restart; preserve partial runtime failure evidence", + "prior_evidence": "unbounded and capped successful samples unchanged" +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/phased-comparison.json b/docs/evidence/2026-10-19-synthetic-sizing/phased-comparison.json new file mode 100644 index 0000000..0b92d10 --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/phased-comparison.json @@ -0,0 +1,209 @@ +{ + "status": "provisional-existing-limit-comparison", + "ordinary": { + "requests": 2433, + "errors": 0, + "p95_seconds": 0.2994377000031818 + }, + "startup_seconds": 7.265825282000151, + "phase_results": [ + { + "name": "ordinary", + "kind": "load", + "seconds": 90.90197221900235, + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "expected_requests": 1533, + "actual_requests": 1533, + "complete": true, + "ordinary": { + "requests": 1255, + "errors": 0, + "p95_seconds": 0.25599331999910646 + }, + "document_transfer": { + "requests": 278, + "errors": 0, + "p95_seconds": 0.1313703150008223 + } + }, + { + "name": "burst", + "kind": "load", + "seconds": 31.090113392001513, + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "expected_requests": 554, + "actual_requests": 554, + "complete": true, + "ordinary": { + "requests": 454, + "errors": 0, + "p95_seconds": 0.30336759299825644 + }, + "document_transfer": { + "requests": 100, + "errors": 0, + "p95_seconds": 0.1089398859985522 + } + }, + { + "name": "restart", + "kind": "restart", + "seconds": 7.128452280001511, + "ready": true + }, + { + "name": "recovery", + "kind": "load", + "seconds": 61.30375194999942, + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "expected_requests": 884, + "actual_requests": 884, + "complete": true, + "ordinary": { + "requests": 724, + "errors": 0, + "p95_seconds": 0.3364859590001288 + }, + "document_transfer": { + "requests": 160, + "errors": 0, + "p95_seconds": 0.13325956499829772 + } + } + ], + "document_transfer": { + "requests": 538, + "errors": 0, + "p95_seconds": 0.12919110099755926 + }, + "complete": true, + "provisional_pass": true, + "measurement_complete": true, + "measurement_gaps": [], + "config": { + "users": 2, + "workers": 2, + "iterations": 100000, + "tenders": 100, + "document_bytes": 1048576, + "think_seconds": 0.2, + "timeout_seconds": 30, + "ordinary_p95_seconds": 2.0, + "duration_seconds": 180, + "idle_seconds": 30, + "runtime": "container", + "app_image": "vergabe-synthetic:local-11f7c8a", + "app_cpu_limit": 1.0, + "app_memory_limit": "1g", + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "phases": [ + { + "name": "ordinary", + "kind": "load", + "duration_seconds": 90, + "think_seconds": 0.2 + }, + { + "name": "burst", + "kind": "load", + "duration_seconds": 30, + "think_seconds": 0 + }, + { + "name": "restart", + "kind": "restart" + }, + { + "name": "recovery", + "kind": "load", + "duration_seconds": 60, + "think_seconds": 0.2 + } + ] + }, + "completed_iterations": { + "ordinary": [ + 70, + 69 + ], + "burst": [ + 25, + 25 + ], + "recovery": [ + 40, + 40 + ] + }, + "app_runtime": { + "image_id": "sha256:57da46165ab113d0395e6885a7215a99a8866fe33731150c2985f6115138ea8b", + "nano_cpus": 1000000000, + "memory_bytes": 1073741824, + "memory_kind": "cgroup-memory-current includes page cache", + "oom_killed": false, + "dockerfile_sha256": "154d116566361c632dced1f83678cac5c111b1ac17344421cdba4f06213fa498" + }, + "application_measured": { + "sample_intervals": 267, + "cpu_p95_m": 1030.8873146524147, + "cpu_mean_m": 936.7925453787296, + "cgroup_memory_peak_mib": 309.8046875, + "throttled_periods": 1376, + "periods": 1813, + "throttled_usec": 104380697 + }, + "database_separate": { + "samples": 89, + "cpu_p95_percent": 74.1, + "memory_snapshots_kind": "docker usage; full intervals retained raw" + }, + "interpretation": "No production limit increase implied. The inspected limit is tested only for this local synthetic mix. 60m request is not validated as adequate under contention. Memory includes page cache; comparison to earlier summedRSS is not equivalent. No human/ingress/SSO acceptance." +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/phased-sizing-run.json.gz b/docs/evidence/2026-10-19-synthetic-sizing/phased-sizing-run.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..bf4fd2385d87f9b6d9a5ea98b5d0c79d0f061102 GIT binary patch literal 62224 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 z¥N4>lmRIAmi!w;baw?F13Yj5!QA3RIQWF*h% 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 bV?K6F`v{_N>DNb+*K!p= a["cpu_seconds"] + and b["monotonic_seconds"] > a["monotonic_seconds"] +] +if not pairs: + raise ValueError("no valid CPU sample intervals") rates = [ (b["cpu_seconds"] - a["cpu_seconds"]) / (b["monotonic_seconds"] - a["monotonic_seconds"]) * 1000 - for a, b in zip(samples, samples[1:]) + for a, b in pairs ] @@ -29,11 +39,16 @@ def p95(values): return sorted(values)[math.ceil(0.95 * len(values)) - 1] -start, end = samples[0]["cpu_stat"], samples[-1]["cpu_stat"] -db = [s["docker_stats"] for s in v["database_samples"] if s["phase"] == "ordinary"] +def counter_delta(key): + return sum(max(0, b["cpu_stat"][key] - a["cpu_stat"][key]) for a, b in pairs) + + +db = [s["docker_stats"] for s in v["database_samples"] if s["phase"] in load_names] report = { "status": "provisional-existing-limit-comparison", "ordinary": v["ordinary"], + "startup_seconds": v.get("startup_seconds"), + "phase_results": v.get("phase_results", []), "document_transfer": v["document_transfer"], "complete": v["complete"], "provisional_pass": v["provisional_pass"], @@ -45,13 +60,13 @@ report = { "application_measured": { "sample_intervals": len(rates), "cpu_p95_m": p95(rates), - "cpu_mean_m": (samples[-1]["cpu_seconds"] - samples[0]["cpu_seconds"]) - / (samples[-1]["monotonic_seconds"] - samples[0]["monotonic_seconds"]) + "cpu_mean_m": sum(b["cpu_seconds"] - a["cpu_seconds"] for a, b in pairs) + / sum(b["monotonic_seconds"] - a["monotonic_seconds"] for a, b in pairs) * 1000, "cgroup_memory_peak_mib": max(s["rss_bytes"] for s in samples) / 1024**2, - "throttled_periods": end["nr_throttled"] - start["nr_throttled"], - "periods": end["nr_periods"] - start["nr_periods"], - "throttled_usec": end["throttled_usec"] - start["throttled_usec"], + "throttled_periods": counter_delta("nr_throttled"), + "periods": counter_delta("nr_periods"), + "throttled_usec": counter_delta("throttled_usec"), }, "database_separate": { "samples": len(db), diff --git a/scripts/synthetic_pilot.py b/scripts/synthetic_pilot.py index dab01ec..99032a8 100644 --- a/scripts/synthetic_pilot.py +++ b/scripts/synthetic_pilot.py @@ -33,6 +33,130 @@ def summarize(rows): } +LEGACY_OPERATIONS = [ + "list", + "search", + "tender-create", + "detail", + "lot-create", + "task-create", + "document-upload", + "document-download", +] +DEPENDENCIES = { + "list": None, + "search": None, + "tender-create": None, + "detail": "tender-create", + "tender-edit": "tender-create", + "lot-create": "tender-create", + "lot-edit": "lot-create", + "task-create": "tender-create", + "task-edit": "task-create", + "document-upload": "tender-create", + "document-download": "document-upload", +} + + +def operation_plan(operations): + if not isinstance(operations, list) or not operations: + raise ValueError("operations must be a nonempty ordered list") + seen = set() + for operation in operations: + if operation not in DEPENDENCIES: + raise ValueError(f"unknown operation: {operation}") + dependency = DEPENDENCIES[operation] + if dependency and dependency not in seen: + raise ValueError(f"{operation} requires earlier {dependency}") + if operation in seen and (operation.endswith("-create") or operation == "document-upload"): + raise ValueError(f"only one {operation} per cycle; repeat reads/edits to tune mix") + seen.add(operation) + return list(operations) + + +def phase_plan(config): + phases = config.get( + "phases", + [ + { + "name": "ordinary", + "kind": "load", + "duration_seconds": config["duration_seconds"], + "think_seconds": config["think_seconds"], + } + ], + ) + if not isinstance(phases, list) or not phases: + raise ValueError("phases must be a nonempty list") + names = set() + result = [] + for item in phases: + item = dict(item) + name = item.get("name") + if not isinstance(name, str) or not name or name in names or name in ("idle", "startup"): + raise ValueError("phase names must be unique and not idle/startup") + names.add(name) + if item.get("kind") == "restart": + if config.get("runtime") != "container": + raise ValueError("restart phase requires container runtime") + elif item.get("kind") == "load": + for key in ("duration_seconds", "think_seconds"): + value = item.get(key) + if type(value) not in (int, float) or not math.isfinite(value) or value < 0: + raise ValueError(f"invalid phase {key}") + if not item["duration_seconds"]: + raise ValueError("load duration must be positive") + item["operations"] = operation_plan( + item.get("operations", config.get("operations", LEGACY_OPERATIONS)) + ) + else: + raise ValueError("phase kind must be load or restart") + result.append(item) + if not any(p["kind"] == "load" for p in result): + raise ValueError("at least one load phase required") + return result + + +def expected_requests(completed, operations, users): + return sum(completed) * len(operations) + users * 2 + + +def wait_ready(base_url, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(base_url + "/health/", timeout=1) as response: + if response.status == 200: + return + except OSError: + pass + time.sleep(0.1) + raise RuntimeError("application readiness timeout") + + +def container_url(name): + binding = subprocess.check_output( + ["docker", "port", name, "8000/tcp"], text=True, timeout=10 + ).strip() + host, port = binding.rsplit(":", 1) + if host != "127.0.0.1" or not port.isdigit(): + raise ValueError("synthetic runtime must have one loopback HTTP binding") + return f"http://127.0.0.1:{port}" + + +def restart_container(name): + subprocess.run( + ["docker", "restart", "--time", "5", name], + check=True, + timeout=20, + stdout=subprocess.DEVNULL, + ) + # Docker can allocate a different random published port after restart. + endpoint = container_url(name) + wait_ready(endpoint) + return endpoint + + def validate(config): for key in ( "users", @@ -68,6 +192,7 @@ def validate(config): 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") + phase_plan(config) return config @@ -103,12 +228,16 @@ def sampling_gaps(samples, db_samples, errors): 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:])): + if any( + b["monotonic_seconds"] - a["monotonic_seconds"] > 3 + for a, b in zip(good, good[1:]) + if a.get("phase") == b.get("phase") + ): gaps.append({"sampler": "application", "reason": "sampling interval exceeds 3 seconds"}) return gaps -def run(config, root): +def run(config, root, failure_output=None): # 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" @@ -133,8 +262,10 @@ def run(config, root): from django.db import connections from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.dokumente.models import Dokument + from vergabe_teilnahme.apps.lose.models import Los call_command("migrate", verbosity=0) for i in range(config["tenders"]): @@ -155,7 +286,9 @@ def run(config, root): sampling_done = threading.Event() sampler_threads = [] sampler_errors = [] + sampling_lock = threading.Lock() try: + startup_started = time.monotonic() if container_mode: app_container = os.environ["SYNTHETIC_CONTAINER"] + "-app" root.chmod(0o755) @@ -204,14 +337,7 @@ def run(config, root): 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}" + base_url = container_url(app_container) else: server = subprocess.Popen( [ @@ -231,40 +357,41 @@ def run(config, root): 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"} + phase = {"name": "startup", "kind": "startup"} 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, + if phase.get("kind") == "restart": + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "phase": phase["name"], + "expected_unavailable": True, + "reason": "planned restart", + } ) + sampling_done.wait(0.5) + continue + if container_mode: + with sampling_lock: + 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()) @@ -342,11 +469,15 @@ def run(config, root): sampler_threads.extend([sampler, db_sampler]) sampler.start() db_sampler.start() + wait_ready(base_url) + startup_seconds = time.monotonic() - startup_started + phase.update(name="idle", kind="idle") time.sleep(config["idle_seconds"]) - phase["name"] = "ordinary" - deadline = time.monotonic() + config["duration_seconds"] - completed = [0] * config["users"] - barrier = threading.Barrier(config["users"]) + plan = phase_plan(config) + active_plan = [] + phase_results = [] + transport_rebinds = [] + phase_completed = {} def worker(i): jar = http.cookiejar.CookieJar() @@ -400,11 +531,14 @@ def run(config, root): received = len(response.read()) status = response.status ok = status == expected + if transfer and method == "get": + ok = ok and received == config["document_bytes"] response.close() except Exception: status, ok, received = None, False, 0 rows.append( { + "phase": phase["name"], "user": i, "operation": operation, "transfer": transfer, @@ -428,59 +562,172 @@ def run(config, root): 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) + title = f"Workload-{phase['name']}-{i}-{n}" + tender = lot = task = document = None + base = None + for index, operation in enumerate(active_plan): + dependency = DEPENDENCIES[operation] + entity = { + "tender-create": tender, + "lot-create": lot, + "task-create": task, + "document-upload": document, + } + if dependency and entity[dependency] is None: + rows.append( + { + "phase": phase["name"], + "user": i, + "operation": operation, + "transfer": operation.startswith("document-"), + "seconds": 0, + "status": None, + "ok": False, + "response_bytes": 0, + "reason": "dependency operation failed", + } + ) + continue + if operation == "list": + request(operation, "get", "/ausschreibungen/") + elif operation == "search": + request(operation, "get", "/ausschreibungen/?q=Synthetic") + elif operation == "tender-create": + if request( + operation, + "post", + "/ausschreibungen/neu/", + {"titel": title, "ausschreiber": "Synthetic"}, + 302, + ): + tender = Ausschreibung.objects.filter(titel=title).first() + base = f"/ausschreibungen/{tender.pk}/" if tender else None + elif operation == "detail": + request(operation, "get", base) + elif operation == "tender-edit": + changed = f"{title} edited-{index}" + request( + operation, + "post", + base + "bearbeiten/", + {"titel": changed, "ausschreiber": "Synthetic edited"}, + 302, + ) + tender.refresh_from_db() + rows[-1]["ok"] &= tender.titel == changed + elif operation == "lot-create": + request( + operation, + "post", + base + "lose/neu/", + {"losnummer": "1", "lostitel": "Synthetic lot"}, + 302, + ) + lot = Los.objects.filter(ausschreibung=tender).first() + elif operation == "lot-edit": + changed = f"Edited lot-{index}" + request( + operation, + "post", + base + f"lose/{lot.pk}/bearbeiten/", + {"losnummer": "1", "lostitel": changed}, + 302, + ) + lot.refresh_from_db() + rows[-1]["ok"] &= lot.lostitel == changed + elif operation == "task-create": + request( + operation, + "post", + base + "aufgaben/neu/", + {"titel": "Synthetic task", "typ": "fachlich", "prioritaet": 2}, + 302, + ) + task = Aufgabe.objects.filter(ausschreibung=tender).first() + elif operation == "task-edit": + changed = f"Edited task-{index}" + request( + operation, + "post", + base + f"aufgaben/{task.pk}/bearbeiten/", + {"titel": changed, "typ": "fachlich", "prioritaet": 1}, + 302, + ) + task.refresh_from_db() + rows[-1]["ok"] &= task.titel == changed and task.prioritaet == 1 + elif operation == "document-upload": + request( + operation, + "post", + base + "dokumente/hochladen/", + {"kategorie": "intern", "version": "1.0"}, + 302, + True, + ) + document = Dokument.objects.filter(ausschreibung=tender).first() + elif operation == "document-download": + request(operation, "get", document.datei.url, transfer=True) completed[i] += 1 - time.sleep(config["think_seconds"]) + time.sleep(active_phase["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] + rows = [] + for active_phase in plan: + phase.update(name=active_phase["name"], kind=active_phase["kind"]) + phase_started = time.monotonic() + if active_phase["kind"] == "restart": + old_url = base_url + with sampling_lock: + base_url = restart_container(app_container) + transport_rebinds.append( + {"phase": active_phase["name"], "before": old_url, "after": base_url} + ) + phase_results.append( + { + "name": active_phase["name"], + "kind": "restart", + "seconds": time.monotonic() - phase_started, + "ready": True, + } + ) + continue + active_plan = active_phase["operations"] + deadline = time.monotonic() + active_phase["duration_seconds"] + completed = [0] * config["users"] + barrier = threading.Barrier(config["users"]) + with concurrent.futures.ThreadPoolExecutor(max_workers=config["users"]) as pool: + phase_rows = [ + r for group in pool.map(worker, range(config["users"])) for r in group + ] + phase_completed[active_phase["name"]] = completed + phase_ordinary = summarize([r for r in phase_rows if not r["transfer"]]) + phase_transfers = summarize([r for r in phase_rows if r["transfer"]]) + expected = expected_requests(completed, active_plan, config["users"]) + phase_results.append( + { + "name": active_phase["name"], + "kind": "load", + "seconds": time.monotonic() - phase_started, + "operations": active_plan, + "expected_requests": expected, + "actual_requests": len(phase_rows), + "complete": all(completed) and len(phase_rows) == expected, + "ordinary": phase_ordinary, + "document_transfer": phase_transfers, + } + ) + rows.extend(phase_rows) 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 + complete = all(p.get("complete", p.get("ready", False)) for p in phase_results) result = { - "schema_version": 1, + "schema_version": 2, + "startup_seconds": startup_seconds, + "transport_rebinds": transport_rebinds, + "phase_results": phase_results, "started_at": started, "finished_at": datetime.now(UTC).isoformat(), "duration_seconds": time.perf_counter() - before, @@ -499,16 +746,20 @@ def run(config, root): "provisional_pass": complete and ordinary["errors"] == 0 and transfers["errors"] == 0 - and ordinary["p95_seconds"] <= config["ordinary_p95_seconds"], + and all( + p["ordinary"]["p95_seconds"] <= config["ordinary_p95_seconds"] + for p in phase_results + if p["kind"] == "load" + ), "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", + "No deployed-release hardware or shared-service contention acceptance", ], "operations": rows, "runtime_samples": samples, "database_samples": db_samples, - "completed_iterations": completed, + "completed_iterations": phase_completed, } sampling_done.set() for thread in sampler_threads: @@ -537,6 +788,26 @@ def run(config, root): "dockerfile_sha256": hashlib.sha256(Path("Dockerfile").read_bytes()).hexdigest(), } return result + except Exception as exc: + if failure_output is not None: + failure = { + "schema_version": 2, + "status": "failed", + "complete": False, + "provisional_pass": False, + "measurement_complete": False, + "error_type": type(exc).__name__, + "phase": locals().get("phase", {"name": "startup"}), + "config": config, + "phase_results": locals().get("phase_results", []), + "operations": locals().get("rows", []), + "runtime_samples": locals().get("samples", []), + "database_samples": locals().get("db_samples", []), + "measurement_gaps": [{"reason": "runtime failure; partial evidence only"}], + "driver_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + } + failure_output.write_text(json.dumps(failure, indent=2) + "\n") + raise finally: sampling_done.set() for thread in sampler_threads: @@ -618,7 +889,7 @@ if __name__ == "__main__": 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 = run(config, Path(directory), args.output) result["postgres_image_id"] = subprocess.check_output( ["docker", "inspect", "--format", "{{.Image}}", name], text=True ).strip() diff --git a/scripts/test_synthetic_pilot.py b/scripts/test_synthetic_pilot.py index 535163f..2bf7a9c 100644 --- a/scripts/test_synthetic_pilot.py +++ b/scripts/test_synthetic_pilot.py @@ -1,8 +1,11 @@ import importlib.util +import json import subprocess import sys +import tempfile import unittest from pathlib import Path +from unittest import mock spec = importlib.util.spec_from_file_location( "pilot", Path(__file__).with_name("synthetic_pilot.py") @@ -54,7 +57,111 @@ class SyntheticContractTests(unittest.TestCase): self.assertEqual(self.config()["ordinary_p95_seconds"], 2) +class PlanTests(unittest.TestCase): + def test_edit_dependency_and_unknown_operation_fail_before_runtime(self): + for operations in [ + ["task-edit"], + ["tender-create", "lot-edit"], + ["unknown"], + ["tender-create", "tender-create"], + ]: + with self.subTest(operations=operations), self.assertRaises(ValueError): + pilot.operation_plan(operations) + + def test_repeated_reads_change_derived_expectation(self): + plan = pilot.operation_plan(["list", "list", "tender-create", "detail", "tender-edit"]) + self.assertEqual(pilot.expected_requests([2, 3], plan, 2), 29) + + def test_restart_requires_container_and_unique_names(self): + base = dict(duration_seconds=1, think_seconds=0) + with self.assertRaises(ValueError): + pilot.phase_plan(base | {"phases": [{"name": "restart", "kind": "restart"}]}) + with self.assertRaises(ValueError): + pilot.phase_plan( + base + | { + "phases": [ + dict(name="x", kind="load", duration_seconds=1, think_seconds=0), + dict(name="x", kind="load", duration_seconds=1, think_seconds=0), + ] + } + ) + + def test_planned_restart_gap_does_not_hide_same_phase_gap(self): + rows = [ + dict(monotonic_seconds=0, phase="ordinary"), + dict(expected_unavailable=True, phase="restart"), + dict(monotonic_seconds=10, phase="recovery"), + ] + self.assertEqual(pilot.sampling_gaps(rows, [{}], []), []) + rows.append(dict(monotonic_seconds=15, phase="recovery")) + self.assertTrue(pilot.sampling_gaps(rows, [{}], [])) + + +class ComparisonTests(unittest.TestCase): + def test_restart_counter_reset_is_not_averaged_across_phase_boundary(self): + samples = [] + for phase, start in [("ordinary", 0), ("recovery", 10)]: + for offset in range(2): + samples.append( + dict( + phase=phase, + monotonic_seconds=start + offset, + cpu_seconds=1 + offset, + rss_bytes=1024**2, + cpu_stat=dict( + nr_throttled=offset, nr_periods=offset, throttled_usec=offset + ), + ) + ) + record = dict( + measurement_complete=True, + measurement_gaps=[], + app_runtime=dict(nano_cpus=1000000000, memory_bytes=1073741824), + runtime_samples=samples, + phase_results=[dict(name=n, kind="load") for n in ["ordinary", "recovery"]], + database_samples=[dict(phase="ordinary", docker_stats={"CPUPerc": "1%"})], + ordinary={}, + document_transfer={}, + complete=True, + provisional_pass=True, + config={}, + completed_iterations={}, + ) + with tempfile.TemporaryDirectory() as tmp: + source, output = Path(tmp) / "input.json", Path(tmp) / "output.json" + source.write_text(json.dumps(record)) + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("compare_synthetic_limits.py")), + str(source), + "--output", + str(output), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + measured = json.loads(output.read_text())["application_measured"] + self.assertEqual(measured["cpu_mean_m"], 1000) + self.assertEqual(measured["throttled_periods"], 2) + + class LifecycleTests(unittest.TestCase): + def test_restart_checks_new_loopback_binding(self): + with ( + mock.patch.object(pilot.subprocess, "run"), + mock.patch.object(pilot, "container_url", return_value="http://127.0.0.1:49002"), + mock.patch.object(pilot, "wait_ready") as ready, + ): + self.assertEqual(pilot.restart_container("synthetic"), "http://127.0.0.1:49002") + ready.assert_called_once_with("http://127.0.0.1:49002") + + def test_public_binding_is_rejected(self): + with mock.patch.object(pilot.subprocess, "check_output", return_value="0.0.0.0:49002"): + with self.assertRaises(ValueError): + pilot.container_url("synthetic") + def test_process_group_cleanup_stops_real_child(self): process = subprocess.Popen( [sys.executable, "-c", "import time; time.sleep(60)"], start_new_session=True