Add load test harness for pool and fleet sizing
Adds the scripts used to size the connection pool and validate fleet polling under load: burst arrival and mixed workload drivers, pool and resource samplers, a UI probe, host seed/drop SQL, and a gevent Dockerfile. Hosts are addressed by compose service name (api:5001) rather than a local path, so the harness runs against any stack rather than one machine. loadtest_token.py mints a scoped collector token named LOADTEST and prints the secret once, then revokes only that token by name so a real collector token is never touched. envkeyfallback is false, so the harness needs a genuine managed token rather than an env key.
This commit is contained in:
78
scripts/loadtest/ui_probe.py
Normal file
78
scripts/loadtest/ui_probe.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Measure UI latency against the API, independent of any load generator.
|
||||
|
||||
WHY separate from poll_fleet.py: when the probe shares an event loop with the
|
||||
simulated fleet, generator-side scheduling can starve the probe and produce UI
|
||||
latencies that are an artifact of the harness rather than of the server. Run
|
||||
this in its own container, concurrently with the fleet, so the two cannot
|
||||
interfere.
|
||||
|
||||
Usage:
|
||||
python ui_probe.py --seconds 90 --interval 1.0
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import statistics
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
index = min(int(len(ordered) * fraction), len(ordered) - 1)
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--seconds', type=int, default=90)
|
||||
parser.add_argument('--interval', type=float, default=1.0)
|
||||
parser.add_argument('--url',
|
||||
default='https://api:5001/api/dashboard/health')
|
||||
parser.add_argument('--timeout', type=float, default=120.0)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
durations = []
|
||||
failures = {}
|
||||
deadline = time.monotonic() + arguments.seconds
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
request = urllib.request.Request(arguments.url)
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=arguments.timeout,
|
||||
context=context) as response:
|
||||
response.read()
|
||||
if response.status == 200:
|
||||
durations.append(time.monotonic() - started)
|
||||
else:
|
||||
key = str(response.status)
|
||||
failures[key] = failures.get(key, 0) + 1
|
||||
except Exception as error:
|
||||
key = type(error).__name__
|
||||
failures[key] = failures.get(key, 0) + 1
|
||||
remaining = arguments.interval - (time.monotonic() - started)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
print(json.dumps({
|
||||
'ui_samples': len(durations) + sum(failures.values()),
|
||||
'ui_ok_200': len(durations),
|
||||
'ui_failures': failures,
|
||||
'ui_median_ms': round(statistics.median(durations) * 1000, 1)
|
||||
if durations else None,
|
||||
'ui_p95_ms': round(percentile(durations, 0.95) * 1000, 1)
|
||||
if durations else None,
|
||||
'ui_max_ms': round(max(durations) * 1000, 1) if durations else None,
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user