Load test of 1000 players on the server: CPU is the ceiling (~135 req/s); player-simulation tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,3 +39,22 @@ python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.j
|
||||
|
||||
سقفِ ساعتیِ دستیار برای مهمانها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در
|
||||
ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان میسازد.
|
||||
|
||||
## بازیکنها (نه دستیار): `mkplayers.py`، `play.py`، `agg.py`
|
||||
|
||||
هر کاربرِ مجازی خانه ← آزمونِ یک جزیره ← ۴ جواب ← درس ← خلاصه را میچرخد، با مکثِ تصادفیِ ۱ تا THINK_MAX ثانیه.
|
||||
کاربرها اشتراکِ آزمایشیِ یکروزه میگیرند (بی دیوارِ پرداخت و بی کمبودِ قلب). روی سرور، مستقیم روی سوکتِ gunicorn:
|
||||
|
||||
```bash
|
||||
cd ~/mathapp-v2 && mkdir -m 700 .lt # اول بکاپِ دیتابیس
|
||||
LT_N=1000 LT_PREFIX=0900770 venv/bin/python manage.py shell -c "exec(open('tools/loadtest/mkplayers.py').read())" \
|
||||
| grep ^LTJSON | cut -c7- > .lt/users.json
|
||||
for p in $(seq 0 9); do venv/bin/python tools/loadtest/play.py /run/gunicorn/mathapp.sock .lt/users.json .lt/out-$p.json \
|
||||
"300:40,600:40,1000:60" 5 40 $p 10 & done; wait
|
||||
venv/bin/python tools/loadtest/agg.py ".lt/out-*.json" "300:40,600:40,1000:60"
|
||||
```
|
||||
|
||||
**مولد را چندپروسهای اجرا کنید (آخرین دو آرگومان).** یک پروسهی httpx با ۶۰۰ اتصال خودش گلوگاه شد: کلاینت ۲۱ ثانیه
|
||||
دید درحالیکه لاگِ اپ برای همان درخواستها ۰٫۲ تا ۰٫۵ ثانیه میگفت (۲۰۲۶-۱۰-۱۳). همیشه `duration_ms`ِ `logs/app.log`
|
||||
(به وقتِ UTC) را با عددِ کلاینت مقایسه کنید. پاک کردن: `delete_users` از راهِ پایتون روی `phone__startswith=LT_PREFIX`
|
||||
(فهرستِ id از `manage.py shell` با خطِ هشدار قاطی میشود)، کشِ رتبهبندی، و `rm -rf .lt`.
|
||||
|
||||
13
tools/loadtest/agg.py
Normal file
13
tools/loadtest/agg.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import json, sys, glob, collections
|
||||
recs = [r for f in sorted(glob.glob(sys.argv[1])) for r in json.load(open(f))]
|
||||
dur = dict(x.split(':') for x in sys.argv[2].split(','))
|
||||
q = lambda a, p: round(a[min(len(a) - 1, int(p * len(a)))] * 1000) if a else None
|
||||
for stage in dur:
|
||||
mine = [r for r in recs if r[0] == stage]
|
||||
lat = sorted(r[3] for r in mine if r[1] != 'ai/ask')
|
||||
codes = collections.Counter(str(r[2]) for r in mine)
|
||||
per = collections.defaultdict(list)
|
||||
for r in mine: per[r[1]].append(r[3])
|
||||
print(json.dumps({'users': int(stage), 'requests': len(lat), 'rps': round(len(lat) / int(dur[stage]), 1), 'p50_ms': q(lat, .5), 'p95_ms': q(lat, .95),
|
||||
'p99_ms': q(lat, .99), 'max_ms': q(lat, 1), 'codes': dict(codes),
|
||||
'per': {k: {'n': len(v), 'p50': q(sorted(v), .5), 'p95': q(sorted(v), .95)} for k, v in per.items()}}, ensure_ascii=False))
|
||||
31
tools/loadtest/mkplayers.py
Normal file
31
tools/loadtest/mkplayers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# manage.py shell -c "exec(open('tools/loadtest/mkplayers.py').read())" env: LT_N, LT_PREFIX
|
||||
# Test players: fake 0900... phones, an active subscription (no paywall, unlimited hearts), a token,
|
||||
# and for each one island of its grade with that island's questions and option ids.
|
||||
import json, os, random
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from content.models import Node, Question
|
||||
from subscription.models import Subscription
|
||||
|
||||
N = int(os.environ['LT_N']); PREFIX = os.environ['LT_PREFIX']
|
||||
rnd = random.Random(11)
|
||||
GRADES = [8, 9, 10, 11, 12, 4, 5, 6, 7, 3]
|
||||
pool = {}
|
||||
for g in GRADES:
|
||||
nodes = []
|
||||
for node in Node.objects.filter(unit__grade_id=g).order_by('?')[:40]:
|
||||
qs = list(Question.objects.filter(lesson__node=node).prefetch_related('choices')[:6])
|
||||
if len(qs) >= 4:
|
||||
nodes.append({'nodeId': node.id, 'lessonId': qs[0].lesson_id,
|
||||
'questions': [{'id': q.id, 'options': [c.id for c in q.choices.all()]} for q in qs]})
|
||||
pool[g] = nodes
|
||||
out = []
|
||||
exp = timezone.now() + timedelta(days=1)
|
||||
for i in range(N):
|
||||
g = GRADES[i % len(GRADES)]
|
||||
u, _ = User.objects.get_or_create(phone=f'{PREFIX}{i:04d}', defaults={'name': f'تست بار {i}', 'grade_id': g})
|
||||
Subscription.objects.update_or_create(user_id=u.id, defaults={'status': 'active', 'expires_at': exp})
|
||||
out.append({'userId': u.id, 'token': issue_access_token(u.id), 'grade': g, **rnd.choice(pool[g])})
|
||||
print('LTJSON' + json.dumps(out))
|
||||
82
tools/loadtest/play.py
Normal file
82
tools/loadtest/play.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Players, not askers: each virtual student loops home -> island quiz -> answers -> lesson -> summary, with a
|
||||
random 1..THINK_MAX s pause between requests. Stages of N users for D seconds each; optional AI asks.
|
||||
usage: python play.py SOCKET users.json OUT.json "300:40,600:40,1000:60" THINK_MAX ASKS_IN_LAST_STAGE
|
||||
"""
|
||||
import asyncio, json, random, sys, time
|
||||
import httpx
|
||||
|
||||
SOCK, USERS, OUT, STAGES, THINK, ASKS = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], float(sys.argv[5]), int(sys.argv[6])
|
||||
PROC, NPROC = int(sys.argv[7]), int(sys.argv[8])
|
||||
users = json.load(open(USERS))
|
||||
stages = [tuple(map(int, s.split(':'))) for s in STAGES.split(',')]
|
||||
H = {'Host': 'mathapi.tadrisino2.ir', 'X-Forwarded-Proto': 'https'}
|
||||
recs = []
|
||||
|
||||
|
||||
async def player(c, u, stop, stage):
|
||||
rnd = random.Random(u['userId'])
|
||||
hdr = {**H, 'Authorization': f"Bearer {u['token']}"}
|
||||
steps = ['/api/home', f"/api/nodes/{u['nodeId']}/quiz"] + ['answer'] * 4 + [f"/api/lessons/{u['lessonId']}/questions", '/api/user/summary']
|
||||
i = rnd.randrange(len(steps))
|
||||
await asyncio.sleep(rnd.uniform(0, THINK))
|
||||
while not stop.is_set():
|
||||
p = steps[i % len(steps)]; i += 1
|
||||
t = time.monotonic()
|
||||
try:
|
||||
if p == 'answer':
|
||||
q = rnd.choice(u['questions'])
|
||||
r = await c.post(f"http://x/api/questions/{q['id']}/answer", json={'selectedOptionId': rnd.choice(q['options'])}, headers=hdr)
|
||||
name = 'answer (POST)'
|
||||
else:
|
||||
r = await c.get(f'http://x{p}', headers=hdr)
|
||||
name = p.split('/')[2] if p.count('/') <= 3 else p.split('/')[2] + '/' + p.split('/')[-1]
|
||||
recs.append((stage, name, r.status_code, time.monotonic() - t))
|
||||
except Exception as e:
|
||||
recs.append((stage, p if p == 'answer' else p.split('/')[2], type(e).__name__, time.monotonic() - t))
|
||||
await asyncio.sleep(rnd.uniform(1, THINK))
|
||||
|
||||
|
||||
async def ask(c, u, delay, stage, out):
|
||||
await asyncio.sleep(delay)
|
||||
t = time.monotonic()
|
||||
try:
|
||||
r = await c.post('http://x/api/ai/ask', json={'userQuestion': f'این را قدم به قدم توضیح بده: سوالِ شمارهی {u["userId"]} دربارهی همین درس', 'lessonId': str(u['lessonId'])},
|
||||
headers={**H, 'Authorization': f"Bearer {u['token']}"}, timeout=120)
|
||||
out.append((stage, 'ai/ask', r.status_code, time.monotonic() - t))
|
||||
except Exception as e:
|
||||
out.append((stage, 'ai/ask', type(e).__name__, time.monotonic() - t))
|
||||
|
||||
|
||||
async def main():
|
||||
tr = httpx.AsyncHTTPTransport(uds=SOCK, retries=0)
|
||||
lim = httpx.Limits(max_connections=200, max_keepalive_connections=200)
|
||||
async with httpx.AsyncClient(transport=tr, limits=lim, timeout=60) as c:
|
||||
summary = []
|
||||
for n, dur in stages:
|
||||
stop = asyncio.Event(); label = f'{n}'
|
||||
start = len(recs); t0 = time.monotonic()
|
||||
tasks = [asyncio.create_task(player(c, u, stop, label)) for i, u in enumerate(users[:n]) if i % NPROC == PROC]
|
||||
ai = []
|
||||
if ASKS and (n, dur) == stages[-1]:
|
||||
tasks += [asyncio.create_task(ask(c, u, k * (dur - 10) / ASKS, label, ai)) for k, u in enumerate(users[-ASKS:]) if k % NPROC == PROC]
|
||||
await asyncio.sleep(dur); stop.set()
|
||||
await asyncio.gather(*tasks)
|
||||
recs.extend(ai)
|
||||
el = time.monotonic() - t0
|
||||
mine = recs[start:]
|
||||
lat = sorted(x[3] for x in mine if x[1] != 'ai/ask')
|
||||
q = lambda a, p: round(a[min(len(a) - 1, int(p * len(a)))] * 1000) if a else None
|
||||
codes = {}
|
||||
for x in mine: codes[str(x[2])] = codes.get(str(x[2]), 0) + 1
|
||||
s = {'users': n, 'seconds': round(el), 'requests': len(mine), 'rps': round(len([x for x in mine if x[1] != 'ai/ask']) / el, 1),
|
||||
'p50_ms': q(lat, .5), 'p95_ms': q(lat, .95), 'p99_ms': q(lat, .99), 'max_ms': q(lat, 1), 'codes': codes}
|
||||
per = {}
|
||||
for x in mine:
|
||||
per.setdefault(x[1], []).append(x[3])
|
||||
s['per'] = {k: {'n': len(v), 'p50': q(sorted(v), .5), 'p95': q(sorted(v), .95)} for k, v in per.items()}
|
||||
summary.append(s)
|
||||
await asyncio.sleep(5)
|
||||
json.dump(recs, open(OUT, 'w'))
|
||||
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user