diff --git a/CLAUDE.md b/CLAUDE.md index bfc2c81..7619e7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -474,6 +474,11 @@ anyone who can read this repo can forge a token for any user.** و `env-before-demo-removal-20261013-190632`. سقفِ پیامکِ روزانه ۲٬۰۰۰. اعتبارِ کاوه‌نگار آن روز ۲۵۳٬۸۴۱ ریال (شارژ: سینیور). - **CPUِ ماشین:** `metabase` و `celerybeat`ِ Artina (مالِ دیگران) بی‌وقفه ری‌استارت می‌شوند (۱۷۶ و ~۱۲۰۰ بار در ساعت) و حدودِ ۳ از ۸ هسته را می‌گیرند. به آن‌ها دست نزن؛ درخواست از ادمینِ سرور. +- **آزمونِ بارِ بازیکن‌ها روی سرور (۱۹:۴۰، `tools/loadtest/play.py`، ۱۰ پروسه، هر کاربر هر ۱ تا ۵ ثانیه یک درخواست):** + ۳۰۰ نفر ۸۸ درخواست/ثانیه، میانه ۰٫۱ ثانیه؛ ۶۰۰ نفر ۱۳۷، میانه ۱٫۴ ثانیه، بی‌خطا؛ ۱٬۰۰۰ نفر سقفِ ~۱۳۵، میانه ۴ و صدکِ ۹۵ ۱۵ ثانیه، + ۳٪ اتصالِ بی‌جواب. هر ۱۶٬۷۶۷ درخواستی که به اپ رسید ۲۰۰ گرفت؛ هیچ ردی برای poolِ دیتابیس نبود. گلوگاه CPU است: ماشین ۹۰ تا ۹۸٪ + مشغول و هر ۴ پروسه‌ی gunicorn نزدیکِ یک هسته. برای ۱٬۰۰۰ نفرِ دسته‌بالا: رفعِ دو سرویسِ خرابِ دیگران + هسته‌ی بیشتر و ۸ worker + (برآورد ~۲۷۰ درخواست/ثانیه؛ با ۸ worker `DB_POOL_MAX_SIZE` ۶ تا ۴۸+۶ ≤ ۵۷). ۸۰ سوالِ واقعیِ دستیار خرج شد. کاربرانِ آزمایشی پاک شدند. - `/etc/nginx` را classifierِ حالتِ خودکار فقط با تأییدِ صریحِ مالک و گام‌به‌گام (بکاپ → نصب و `nginx -t` → reload) اجازه داد. ## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`) diff --git a/tools/loadtest/README.md b/tools/loadtest/README.md index 574dead..7357e5a 100644 --- a/tools/loadtest/README.md +++ b/tools/loadtest/README.md @@ -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`. diff --git a/tools/loadtest/agg.py b/tools/loadtest/agg.py new file mode 100644 index 0000000..b5cc26f --- /dev/null +++ b/tools/loadtest/agg.py @@ -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)) diff --git a/tools/loadtest/mkplayers.py b/tools/loadtest/mkplayers.py new file mode 100644 index 0000000..c745953 --- /dev/null +++ b/tools/loadtest/mkplayers.py @@ -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)) diff --git a/tools/loadtest/play.py b/tools/loadtest/play.py new file mode 100644 index 0000000..877cf5a --- /dev/null +++ b/tools/loadtest/play.py @@ -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())