From 11f7c8ae830a7c96779fe7dbe7ae71a322e6b832 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 14:54:19 +0200 Subject: [PATCH] feat: measure configurable synthetic pilot workload in isolated PostgreSQL runtime Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- SCOPE.md | 13 + config/synthetic-pilot.json | 12 + config/synthetic-sizing-margins.json | 6 + .../2026-10-19-synthetic-sizing/README.md | 44 ++ .../executed-sizing-driver.py | 292 +++++++++++ .../exploratory.json.gz | Bin 0 -> 6720 bytes .../sizing-envelope.json | 30 ++ .../sizing-run.json.gz | Bin 0 -> 51629 bytes docs/synthetic-pilot.md | 82 +++ scripts/summarize_synthetic_sizing.py | 78 +++ scripts/synthetic_pilot.py | 479 ++++++++++++++++++ scripts/test_synthetic_pilot.py | 98 ++++ 12 files changed, 1134 insertions(+) create mode 100644 config/synthetic-pilot.json create mode 100644 config/synthetic-sizing-margins.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/README.md create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/executed-sizing-driver.py create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/exploratory.json.gz create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/sizing-envelope.json create mode 100644 docs/evidence/2026-10-19-synthetic-sizing/sizing-run.json.gz create mode 100644 docs/synthetic-pilot.md create mode 100644 scripts/summarize_synthetic_sizing.py create mode 100644 scripts/synthetic_pilot.py create mode 100644 scripts/test_synthetic_pilot.py diff --git a/SCOPE.md b/SCOPE.md index 6e5185f..3dfde1d 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -189,3 +189,16 @@ existing bytes remain available for offline session startup; refreshing it does not require a Git commit. Authored workplans and the generated WORK-RECORDS index retain their existing source and staged-generation contracts. No runtime or credential change follows from this repository hygiene policy. + + +## Provisional synthetic pilot sizing + +`scripts/synthetic_pilot.py` drives real loopback HTTP through an isolated Gunicorn +instance and disposable PostgreSQL16. `config/synthetic-pilot.json` tunes workload +assumptions, and `config/synthetic-sizing-margins.json` tunes the conservative local +resource-envelope calculation. Per-operation latency/errors and separate document +transfer, process CPU/RSS and database snapshots are retained. The 2026-10-19 local +sample passes the two-user ordinary p95<=2s/zero-error criterion; it does not establish +human acceptance or the production60m CPU request. Coverage and provisional values +are in `docs/synthetic-pilot.md` and `docs/evidence/2026-10-19-synthetic-sizing/`. +No deployment or publication is performed by these tools. diff --git a/config/synthetic-pilot.json b/config/synthetic-pilot.json new file mode 100644 index 0000000..c6a7916 --- /dev/null +++ b/config/synthetic-pilot.json @@ -0,0 +1,12 @@ +{ + "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 +} diff --git a/config/synthetic-sizing-margins.json b/config/synthetic-sizing-margins.json new file mode 100644 index 0000000..4cb080a --- /dev/null +++ b/config/synthetic-sizing-margins.json @@ -0,0 +1,6 @@ +{ + "cpu_request_multiplier": 1.5, + "cpu_limit_multiplier": 1.5, + "memory_request_multiplier": 1.25, + "memory_limit_multiplier": 1.5 +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/README.md b/docs/evidence/2026-10-19-synthetic-sizing/README.md new file mode 100644 index 0000000..5e44ffd --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/README.md @@ -0,0 +1,44 @@ +# Isolated synthetic pilot sizing — 2026-10-19 + +The user authorized provisional synthetic sizing with adjustable assumptions. +Run locally against fresh PostgreSQL16 and loopback Gunicorn, not production. +Two users, two workers,100 initial tenders,1MiB documents,0.2-second think time. +30s idle then180.889s monotonic ordinary duration;378 complete cycles. + +| Result | Value | +| --- | --- | +| Ordinary requests / errors / p95 | 2272 / 0 / 0.226793s | +| Document transfer requests / errors / p95 | 756 / 0 / 0.095466s | +| Aggregate app CPU p95 / observed peak | 1298.30m / 1916.96m | +| Peak summed app RSS | 193.84MiB | +| Process samples / database interval snapshots | 422 / 103 | +| Conservative local CPU request / limit candidate | 1950m / 2900m | +| Conservative local memory request / limit candidate | 256Mi / 320Mi | + +This ~16.7-requests/second synthetic mix is deliberately aggressive. It is not +measured human demand. This unbounded local envelope exceeds the accepted production1000m CPU limit +and the current395m reservation residual reported by the allocation owner. It is +not a recommendation to raise that limit. The next comparison is an isolated +application-container run at the existing1000m/1Gi limit;60m is a request, not a quota. +The candidate envelope derives from configurable margins +in `config/synthetic-sizing-margins.json`; no deployment change is made or implied. +The eight-logical-CPU Ryzen5 PRO3500U host had no harness app CPU quota. Neither +these results nor the passing latency criterion prove the existing60m production +request. Database snapshots are retained separately and not charged to app demand. +RSS sums shared resident pages more than once; per-worker CPU is aggregate/2, +not independently observed worker load. Idle is measured; startup timing, restart, +editing, separate bursts, browser/ingress/SSO and production quota/throttling are not. +Repeat with revised assumptions or approved target-runtime constraints before +production right-sizing. Human pilot acceptance remains open. + +`sizing-run.json.gz` preserves raw per-operation and resource samples, source/config/ +image digests and hardware. `sizing-envelope.json` is reproducible with the summary +command in `docs/synthetic-pilot.md`. `executed-sizing-driver.py` is the exact run +snapshot matching its retained digest; the maintained script subsequently received +only import/format lint cleanup. `exploratory.json.gz` retains the earlier15.7s +sample, not used as the sizing recommendation. Use monotonic duration for rates; +UTC boundaries are retained as observed and may differ due to wall-clock adjustment. + +Validation: six focused configuration/percentile/sizing tests; Ruff clean for +maintained scripts; complete real HTTP run passed all3028 operations. No customer +records, external identity, live credentials or production writes were used. diff --git a/docs/evidence/2026-10-19-synthetic-sizing/executed-sizing-driver.py b/docs/evidence/2026-10-19-synthetic-sizing/executed-sizing-driver.py new file mode 100644 index 0000000..0e314d1 --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/executed-sizing-driver.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Provisional loopback HTTP workload using Gunicorn and disposable PostgreSQL.""" +import argparse +import atexit +import concurrent.futures +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import socket +import subprocess +import urllib.request +import urllib.parse +import urllib.error +import http.cookiejar +import uuid +import sys +import tempfile +import threading +import time +from datetime import datetime, timezone + + +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) * .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') + return config + + +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' + (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 = {str(root / "media")!r}\n' + f'ISSUE_FACADE_LOCAL_DB = {str(root / "issues.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, connection + 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 = subprocess.Popen([sys.executable, '-m', 'gunicorn', + 'vergabe_teilnahme.wsgi:application', '--bind', f'127.0.0.1:{port}', + '--workers', str(config['workers']), '--timeout', '30'], env=env, + stdout=server_log, stderr=server_log) + atexit.register(lambda: server.terminate() if server.poll() is None else None) + 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.poll() is not None: + raise RuntimeError('Gunicorn startup failed; see isolated server log') + time.sleep(.1) + else: + server.terminate() + raise RuntimeError('Gunicorn readiness timeout') + samples = [] + db_samples = [] + sampling_done = threading.Event() + phase = {'name': 'idle'} + def sample(): + ticks_per_second = os.sysconf('SC_CLK_TCK') + while not sampling_done.is_set(): + 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(timezone.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(timezone.utc).isoformat(), 'sample_error': True}) + sampling_done.wait(.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) + db_samples.append({'at': datetime.now(timezone.utc).isoformat(), + 'phase': phase['name'], 'docker_stats': json.loads(value)}) + sampling_done.wait(1) + sampler = threading.Thread(target=sample, daemon=True) + db_sampler = threading.Thread(target=sample_db, daemon=True) + 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; name="{name}"\r\n\r\n{value}\r\n'.encode() + body += (f'--{boundary}\r\nContent-Disposition: form-data; name="datei"; filename="synthetic.pdf"\r\nContent-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(timezone.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(timezone.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, SSO or application container quota', + 'No CPU/memory allocation recommendation or human acceptance', + 'No timed startup/restart or distinct burst; configured idle and ordinary phases only'], + 'operations': rows, 'runtime_samples': samples, 'database_samples': db_samples, + 'completed_iterations': completed} + sampling_done.set() + sampler.join() + db_sampler.join() + server.terminate() + server.wait(timeout=10) + server_log.close() + connections.close_all() + return result + + +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] + # 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', + 'postgres:16-alpine'], check=True, stdout=subprocess.DEVNULL) + try: + 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(.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], check=True, + 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': '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'] else 1) diff --git a/docs/evidence/2026-10-19-synthetic-sizing/exploratory.json.gz b/docs/evidence/2026-10-19-synthetic-sizing/exploratory.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..3dcb3b63243f10a43127b6a0aecf87f92dccb702 GIT binary patch literal 6720 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 Wqsy|Ko&4tK-~2zFTt5=$7y$s6rSM1q literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-19-synthetic-sizing/sizing-envelope.json b/docs/evidence/2026-10-19-synthetic-sizing/sizing-envelope.json new file mode 100644 index 0000000..ca3c675 --- /dev/null +++ b/docs/evidence/2026-10-19-synthetic-sizing/sizing-envelope.json @@ -0,0 +1,30 @@ +{ + "status": "provisional-local-host-only", + "application_observed": { + "cpu_p95_m": 1298.300558321979, + "cpu_peak_m": 1916.960754568632, + "rss_sum_peak_mib": 193.8359375, + "sample_intervals": 361, + "normalized_cpu_p95_m_per_worker": 649.1502791609895 + }, + "suggested_application": { + "cpu_request_m": 1950, + "cpu_limit_m": 2900, + "memory_request_mib": 256, + "memory_limit_mib": 320 + }, + "margins": { + "cpu_request_multiplier": 1.5, + "cpu_limit_multiplier": 1.5, + "memory_request_multiplier": 1.25, + "memory_limit_multiplier": 1.5 + }, + "limits": [ + "Per-worker number is normalized aggregate, not an independently sampled worker.", + "RSS sum double-counts shared pages; conservative upper bound, not unique memory.", + "Observed local CPU is hardware/think-time dependent; quota effects untested.", + "Database/container demand is separate and not included in application request.", + "No production apply or human acceptance; validate target runtime before right-sizing." + ], + "revisit": "Changed concurrency, corpus, document size, workers, image, hardware or quota; then repeat under approved target runtime including bursts and restart." +} diff --git a/docs/evidence/2026-10-19-synthetic-sizing/sizing-run.json.gz b/docs/evidence/2026-10-19-synthetic-sizing/sizing-run.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..6a86339997d7cfce7fede52826e3341155baf8b2 GIT binary patch literal 51629 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 QXQgtlDUQnz%)E4kPpYIhXiPguU=4(TM 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 0 and used >= 0: + cpu.append(used / elapsed * 1000) + memory.append(after["rss_bytes"] / 1024**2) + if not cpu or not memory or not result["complete"] or not result["provisional_pass"]: + raise ValueError("incomplete or failed workload cannot establish a sizing envelope") + cpu_p95, cpu_peak, memory_peak = percentile(cpu), max(cpu), max(memory) + + def rounded(value, quantum): + return math.ceil(value / quantum) * quantum + + return { + "status": "provisional-local-host-only", + "application_observed": { + "cpu_p95_m": cpu_p95, + "cpu_peak_m": cpu_peak, + "rss_sum_peak_mib": memory_peak, + "sample_intervals": len(cpu), + "normalized_cpu_p95_m_per_worker": cpu_p95 / result["config"]["workers"], + }, + "suggested_application": { + "cpu_request_m": rounded(cpu_p95 * margin["cpu_request_multiplier"], 25), + "cpu_limit_m": rounded(cpu_peak * margin["cpu_limit_multiplier"], 100), + "memory_request_mib": rounded(memory_peak * margin["memory_request_multiplier"], 64), + "memory_limit_mib": rounded(memory_peak * margin["memory_limit_multiplier"], 64), + }, + "margins": margin, + "limits": [ + "Per-worker number is normalized aggregate, not an independently sampled worker.", + "RSS sum double-counts shared pages; conservative upper bound, not unique memory.", + "Observed local CPU is hardware/think-time dependent; quota effects untested.", + "Database/container demand is separate and not included in application request.", + "No production apply or human acceptance; validate target runtime before right-sizing.", + ], + "revisit": "Changed concurrency, corpus, document size, workers, image, hardware or quota; " + "then repeat under approved target runtime including bursts and restart.", + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("result", type=Path) + parser.add_argument("--margins", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + margins = json.loads(args.margins.read_text()) + if any( + not isinstance(v, (int, float)) or not math.isfinite(v) or v < 1 for v in margins.values() + ): + raise ValueError("all margins must be finite and at least one") + args.output.write_text( + json.dumps(recommend(json.loads(args.result.read_text()), margins), indent=2) + "\n" + ) diff --git a/scripts/synthetic_pilot.py b/scripts/synthetic_pilot.py new file mode 100644 index 0000000..99ef103 --- /dev/null +++ b/scripts/synthetic_pilot.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +"""Provisional loopback HTTP workload using Gunicorn and disposable PostgreSQL.""" + +import argparse +import atexit +import concurrent.futures +import hashlib +import http.cookiejar +import json +import math +import os +import platform +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") + return config + + +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" + (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 = {str(root / 'media')!r}\n" + f"ISSUE_FACADE_LOCAL_DB = {str(root / 'issues.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 = subprocess.Popen( + [ + sys.executable, + "-m", + "gunicorn", + "vergabe_teilnahme.wsgi:application", + "--bind", + f"127.0.0.1:{port}", + "--workers", + str(config["workers"]), + "--timeout", + "30", + ], + env=env, + stdout=server_log, + stderr=server_log, + ) + atexit.register(lambda: server.terminate() if server.poll() is None else None) + 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.poll() is not None: + raise RuntimeError("Gunicorn startup failed; see isolated server log") + time.sleep(0.1) + else: + server.terminate() + raise RuntimeError("Gunicorn readiness timeout") + samples = [] + db_samples = [] + sampling_done = threading.Event() + phase = {"name": "idle"} + + def sample(): + ticks_per_second = os.sysconf("SC_CLK_TCK") + while not sampling_done.is_set(): + 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, + ) + db_samples.append( + { + "at": datetime.now(UTC).isoformat(), + "phase": phase["name"], + "docker_stats": json.loads(value), + } + ) + sampling_done.wait(1) + + sampler = threading.Thread(target=sample, daemon=True) + db_sampler = threading.Thread(target=sample_db, daemon=True) + 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, SSO or application container quota", + "No CPU/memory allocation recommendation or human acceptance", + "No timed startup/restart or distinct burst; configured idle and ordinary phases only", + ], + "operations": rows, + "runtime_samples": samples, + "database_samples": db_samples, + "completed_iterations": completed, + } + sampling_done.set() + sampler.join() + db_sampler.join() + server.terminate() + server.wait(timeout=10) + server_log.close() + connections.close_all() + return result + + +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] + # 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", + "postgres:16-alpine", + ], + check=True, + stdout=subprocess.DEVNULL, + ) + try: + 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], + check=True, + 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": "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"] else 1) diff --git a/scripts/test_synthetic_pilot.py b/scripts/test_synthetic_pilot.py new file mode 100644 index 0000000..5e546af --- /dev/null +++ b/scripts/test_synthetic_pilot.py @@ -0,0 +1,98 @@ +import importlib.util +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "pilot", Path(__file__).with_name("synthetic_pilot.py") +) +pilot = importlib.util.module_from_spec(spec) +spec.loader.exec_module(pilot) + + +class SyntheticContractTests(unittest.TestCase): + def config(self): + return dict( + users=2, + workers=2, + iterations=20, + tenders=100, + document_bytes=1024, + think_seconds=0.2, + timeout_seconds=30, + ordinary_p95_seconds=2, + duration_seconds=180, + idle_seconds=30, + ) + + def test_invalid_workload_fails_before_runtime(self): + for key, value in [ + ("users", 0), + ("users", True), + ("workers", -1), + ("iterations", 1.2), + ("document_bytes", 7), + ("timeout_seconds", 0), + ("duration_seconds", 0), + ("idle_seconds", -1), + ("ordinary_p95_seconds", float("nan")), + ("think_seconds", -1), + ]: + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + pilot.validate(self.config() | {key: value}) + + def test_nearest_rank_p95_and_errors_include_failed_requests(self): + rows = [{"seconds": n, "ok": n != 20} for n in range(1, 21)] + self.assertEqual(pilot.summarize(rows), {"requests": 20, "errors": 1, "p95_seconds": 19}) + + def test_empty_measurement_is_unknown(self): + self.assertIsNone(pilot.summarize([])["p95_seconds"]) + + def test_default_is_two_users_and_two_seconds(self): + self.assertEqual(pilot.validate(self.config())["users"], 2) + self.assertEqual(self.config()["ordinary_p95_seconds"], 2) + + +class SizingTests(unittest.TestCase): + def test_failed_measurement_does_not_recommend(self): + spec = importlib.util.spec_from_file_location( + "sizing", Path(__file__).with_name("summarize_synthetic_sizing.py") + ) + sizing = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sizing) + with self.assertRaises(ValueError): + sizing.recommend({"runtime_samples": [], "complete": False}, {}) + + def test_cpu_delta_and_rounding(self): + spec = importlib.util.spec_from_file_location( + "sizing", Path(__file__).with_name("summarize_synthetic_sizing.py") + ) + sizing = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sizing) + rows = [ + dict( + phase="ordinary", + pids=[1], + monotonic_seconds=i, + cpu_seconds=i * 0.1, + rss_bytes=100 * 1024**2, + ) + for i in range(3) + ] + margin = dict( + cpu_request_multiplier=1.5, + cpu_limit_multiplier=1.5, + memory_request_multiplier=1.25, + memory_limit_multiplier=1.5, + ) + result = sizing.recommend( + dict(runtime_samples=rows, complete=True, provisional_pass=True, config={"workers": 2}), + margin, + ) + self.assertEqual( + result["suggested_application"], + dict(cpu_request_m=150, cpu_limit_m=200, memory_request_mib=128, memory_limit_mib=192), + ) + + +if __name__ == "__main__": + unittest.main()