97 lines
5.1 KiB
Python
97 lines
5.1 KiB
Python
"""Real loopback HTTP task load with a separate, temporary Uvicorn process."""
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from time import perf_counter
|
|
|
|
import httpx
|
|
|
|
|
|
def stats(values):
|
|
values = sorted(values)
|
|
return {'count': len(values), 'p95_ms': round(values[math.ceil(len(values)*.95)-1], 2),
|
|
'max_ms': round(values[-1], 2)} if values else {'count': 0}
|
|
|
|
|
|
async def main(args):
|
|
with tempfile.TemporaryDirectory(prefix='notes-task-http-') as directory:
|
|
root = Path(directory)
|
|
env = {**os.environ, 'APP_DATA_DIR': str(root/'data'), 'APP_DB_PATH': str(root/'app.db'), 'APP_VAULT_PATH': str(root/'vault')}
|
|
with socket.socket() as sock:
|
|
sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
|
|
process = subprocess.Popen([sys.executable, '-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', str(port), '--log-level', 'error'],
|
|
cwd=Path(__file__).resolve().parents[1], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
|
try:
|
|
async with httpx.AsyncClient(base_url=f'http://127.0.0.1:{port}', timeout=30) as client:
|
|
for _ in range(150):
|
|
if process.poll() is not None: raise RuntimeError('Isolated Uvicorn exited')
|
|
try:
|
|
(await client.get('/health')).raise_for_status(); break
|
|
except httpx.HTTPError: await asyncio.sleep(.1)
|
|
else: raise TimeoutError('Isolated Uvicorn startup')
|
|
sem = asyncio.Semaphore(args.concurrency)
|
|
timings = {key: [] for key in ('create', 'update', 'list', 'delete', 'health')}
|
|
errors = []
|
|
async def request(method, path, kind, **kwargs):
|
|
start = perf_counter()
|
|
response = await client.request(method, path, **kwargs)
|
|
timings[kind].append((perf_counter()-start)*1000)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
async def health():
|
|
while True:
|
|
try: await request('GET', '/health', 'health')
|
|
except httpx.HTTPError as error: errors.append(type(error).__name__)
|
|
await asyncio.sleep(.05)
|
|
heartbeat = asyncio.create_task(health())
|
|
start = perf_counter()
|
|
try:
|
|
async def create(index):
|
|
async with sem:
|
|
return (await request('POST', '/api/tasks', 'create', json={'title': f'HTTP 压测 {index}'}))['task_id']
|
|
ids = await asyncio.gather(*(create(i) for i in range(args.count)))
|
|
seen = []
|
|
for offset in range(0, args.count, 100):
|
|
page = await request('GET', f'/api/tasks?limit=100&offset={offset}', 'list')
|
|
seen.extend(item['task_id'] for item in page['items'])
|
|
assert set(seen) == set(ids) and len(seen) == args.count
|
|
async def change(task_id):
|
|
async with sem:
|
|
updated = await request('PATCH', f'/api/tasks/{task_id}', 'update', json={'status': 'done'})
|
|
assert updated['status'] == 'done'
|
|
await request('DELETE', f'/api/tasks/{task_id}', 'delete')
|
|
await asyncio.gather(*(change(task_id) for task_id in ids))
|
|
remaining = await request('GET', '/api/tasks', 'list')
|
|
assert remaining['page']['total'] == 0
|
|
finally:
|
|
heartbeat.cancel(); await asyncio.gather(heartbeat, return_exceptions=True)
|
|
report = {'transport': 'real loopback HTTP, separate Uvicorn process', 'tasks': args.count,
|
|
'concurrency': args.concurrency, 'elapsed_ms': round((perf_counter()-start)*1000, 2),
|
|
'latencies': {key: stats(value) for key,value in timings.items()}, 'health_errors': errors,
|
|
'pagination_complete': True, 'final_total': 0}
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
print(json.dumps(report, ensure_ascii=False))
|
|
finally:
|
|
process.terminate()
|
|
try: process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired: process.kill(); process.wait()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
parser.add_argument('--count', type=int, default=1000)
|
|
parser.add_argument('--concurrency', type=int, default=20)
|
|
args = parser.parse_args()
|
|
if args.count < 1 or args.concurrency < 1: parser.error('count and concurrency must be positive')
|
|
asyncio.run(main(args))
|