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:
2026-10-13 19:48:00 +03:30
parent ba4ecff29e
commit e3c8962b7b
5 changed files with 150 additions and 0 deletions

View File

@@ -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
View 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))

View 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
View 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())