Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools
- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
with the tests that hold them. Comments describe current behaviour, not history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
41
tools/loadtest/README.md
Normal file
41
tools/loadtest/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# لودتستِ دستیار
|
||||
|
||||
سه اسکریپت، برای اینکه «چند دانشآموزِ همزمان» هر بار با یک روش سنجیده شود. خروجیِ هر اجرا یک
|
||||
JSON است با زمانِ هر پرسش و هر درخواستِ صفحهی عادی؛ جمعبندی در خودِ خروجیِ ترمینال چاپ میشود.
|
||||
|
||||
| فایل | کار |
|
||||
|---|---|
|
||||
| `mkusers.py` | کاربرانِ آزمایشیِ غیرمهمان (شمارهی جعلیِ `0900…`، بیپیامک، بیسیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام |
|
||||
| `loadtest.py` | N نفر همزمان میپرسند (استریم، دقیقاً مثلِ فرانت) و B نفرِ دیگر در خانه/آزمون/خلاصه/سهمیه میچرخند |
|
||||
| `fake_upstream.py` | جایگزینِ GapGPT و Qdrant برای آزمایشگاه: embedding، جوابِ استریم با زمانِ لاگنرمال (پیشفرض: میانهی ۵٫۲ و صدکِ ۹۰ِ ۱۳ ثانیه، از جوابهای واقعیِ سرور) و جستوجوی Qdrant |
|
||||
|
||||
## آزمایشگاه (بیهزینه، روی لپتاپ)
|
||||
|
||||
روی یک کپی از پروژه اجرا کنید تا دیتابیسِ اصلیِ لوکال پر نشود. Qdrant هم شبیهسازی میشود، چون
|
||||
از لپتاپ پهنای باندِ بالارفتن (هر بردار ~۳۰ کیلوبایت) جستوجوها را سریالی نشان میدهد؛ از خودِ
|
||||
سرور ۲۰ جستوجوی همزمان ۰٫۲۶ ثانیه است.
|
||||
|
||||
```bash
|
||||
python tools/loadtest/fake_upstream.py & # :8901 (FAKE_SIGMA=0 یعنی همیشه ۵٫۲ ثانیه)
|
||||
export DJANGO_DEBUG=0 CACHE_URL= NUM_PROXIES=0 AI_DAILY_QUOTA_FREE=1000 HEARTS_ENFORCED=0 RAG_FORCE_MOCK=0 \
|
||||
DJANGO_ALLOWED_HOSTS=127.0.0.1 OPENAI_BASE_URL=http://127.0.0.1:8901/v1 OPENAI_API_KEY=x \
|
||||
QDRANT_URL=http://127.0.0.1:8901 QDRANT_API_KEY=x
|
||||
LT_N=150 python manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" | grep ^LTJSON | cut -c7- > users.json
|
||||
gunicorn --bind 127.0.0.1:8800 config.wsgi:application & # همان gunicorn.conf.py ِ سرور
|
||||
python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.json --writes
|
||||
```
|
||||
|
||||
پیش از هر اجرا gunicorn را از نو بالا بیاورید (کشِ جوابِ هر worker خالی شود) و مطمئن شوید پروسهی
|
||||
قبلی واقعاً رفته است؛ وگرنه سوالهای تکراری از کش در ۰٫۱ ثانیه جواب میگیرند و عدد دروغ میگوید.
|
||||
|
||||
## سرورِ اصلی (هزینهی واقعی: ~۰٫۱۸ سنت برای هر جواب)
|
||||
|
||||
۱. بکاپ از دیتابیس (`docs/operations.md`). ۲. کاربرانِ آزمایشی را روی سرور با `mkusers.py` بسازید
|
||||
(`LT_PREFIX` یک پیشوندِ جعلی که روی سرور نیست) و خروجی را فقط در یک فایلِ 600 نگه دارید.
|
||||
۳. `loadtest.py` را با `https://mathapi.tadrisino2.ir` و بدونِ `--writes` اجرا کنید.
|
||||
۴. زمانِ داخلِ GapGPT برای هر پیام در `ai_tutor.Message.latency_ms` است؛ اختلافِ آن با زمانِ کلاینت
|
||||
یعنی کارِ سرورِ ما و صفِ انتظار برای نخِ آزاد. ۵. `manage.py delete_users --ids … --yes` و پاک کردنِ
|
||||
فایلِ توکنها.
|
||||
|
||||
سقفِ ساعتیِ دستیار برای مهمانها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در
|
||||
ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان میسازد.
|
||||
130
tools/loadtest/fake_upstream.py
Normal file
130
tools/loadtest/fake_upstream.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Fake GapGPT + Qdrant for load testing (embeddings, streaming chat, Qdrant points/query). Local only.
|
||||
|
||||
Timing profile is calibrated from the last 54 real assistant answers on the server
|
||||
(Message.latency_ms, gpt-4.1-mini via GapGPT): median 5.2 s, p90 13.1 s, max 30 s.
|
||||
Lognormal(median=FAKE_MEDIAN, sigma=FAKE_SIGMA), capped at FAKE_CAP.
|
||||
|
||||
FAKE_MODE=buffered -> wait the whole latency, then emit all chunks (what GapGPT was
|
||||
observed to do: first chunk arrives with the last).
|
||||
FAKE_MODE=stream -> first token after 20% of latency, rest spread over the remainder.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
MEDIAN = float(os.environ.get('FAKE_MEDIAN', '5.2'))
|
||||
SIGMA = float(os.environ.get('FAKE_SIGMA', '0.715'))
|
||||
CAP = float(os.environ.get('FAKE_CAP', '30'))
|
||||
MODE = os.environ.get('FAKE_MODE', 'buffered')
|
||||
EMB_DELAY = float(os.environ.get('FAKE_EMB_DELAY', '0.35'))
|
||||
QDRANT_DELAY = float(os.environ.get('FAKE_QDRANT_DELAY', '0.08'))
|
||||
PORT = int(os.environ.get('FAKE_PORT', '8901'))
|
||||
|
||||
ANSWER = ('برای حلِ این مسئله اول دادهها را مینویسیم. '
|
||||
'بعد قدمبهقدم جلو میرویم و هر مرحله را وارسی میکنیم. ') * 14 + '✅ جواب: ۱۲'
|
||||
|
||||
active = 0
|
||||
peak = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _json(self, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self._json({'active': active, 'peak': peak})
|
||||
|
||||
def do_POST(self):
|
||||
global active, peak
|
||||
n = int(self.headers.get('Content-Length') or 0)
|
||||
req = json.loads(self.rfile.read(n) or b'{}')
|
||||
if '/points/query' in self.path:
|
||||
time.sleep(QDRANT_DELAY)
|
||||
pts = [{'id': i + 1, 'version': 1, 'score': 0.5 - i * 0.01,
|
||||
'payload': {'lesson_title': 'درس نمونه', 'problem': 'مسئلهی نمونه ' * 30,
|
||||
'generalSolution': 'روشِ کلی ' * 40, 'solution': 'راهحل ' * 60}}
|
||||
for i in range(int(req.get('limit') or 4))]
|
||||
self._json({'result': {'points': pts}, 'status': 'ok', 'time': 0.001})
|
||||
return
|
||||
if self.path.endswith('/embeddings'):
|
||||
time.sleep(EMB_DELAY)
|
||||
vec = [random.uniform(-1, 1) for _ in range(1536)]
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
self._json({'object': 'list', 'model': req.get('model'),
|
||||
'data': [{'object': 'embedding', 'index': 0, 'embedding': [v / norm for v in vec]}],
|
||||
'usage': {'prompt_tokens': 20, 'total_tokens': 20}})
|
||||
return
|
||||
with lock:
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
try:
|
||||
latency = min(CAP, MEDIAN * math.exp(random.gauss(0, SIGMA)))
|
||||
words = ANSWER.split(' ')
|
||||
pieces = [' '.join(words[i:i + 5]) + ' ' for i in range(0, len(words), 5)]
|
||||
if not req.get('stream'):
|
||||
time.sleep(latency)
|
||||
self._json({'id': 'x', 'object': 'chat.completion', 'created': int(time.time()),
|
||||
'model': req.get('model'),
|
||||
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': ANSWER},
|
||||
'finish_reason': 'stop'}],
|
||||
'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}})
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/event-stream')
|
||||
self.send_header('Transfer-Encoding', 'chunked')
|
||||
self.end_headers()
|
||||
|
||||
def send(obj):
|
||||
data = f'data: {json.dumps(obj, ensure_ascii=False)}\n\n'.encode()
|
||||
self.wfile.write(f'{len(data):x}\r\n'.encode() + data + b'\r\n')
|
||||
self.wfile.flush()
|
||||
|
||||
def chunk(text, finish=None):
|
||||
return {'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()),
|
||||
'model': req.get('model'),
|
||||
'choices': [{'index': 0, 'delta': {'content': text} if text else {},
|
||||
'finish_reason': finish}]}
|
||||
|
||||
if MODE == 'buffered':
|
||||
time.sleep(latency)
|
||||
gap = 0.0
|
||||
else:
|
||||
time.sleep(latency * 0.2)
|
||||
gap = latency * 0.8 / len(pieces)
|
||||
for p in pieces:
|
||||
send(chunk(p))
|
||||
if gap:
|
||||
time.sleep(gap)
|
||||
send(chunk('', 'stop'))
|
||||
send({'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()),
|
||||
'model': req.get('model'), 'choices': [],
|
||||
'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}})
|
||||
last = b'data: [DONE]\n\n'
|
||||
self.wfile.write(f'{len(last):x}\r\n'.encode() + last + b'\r\n0\r\n\r\n')
|
||||
self.wfile.flush()
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ThreadingHTTPServer.daemon_threads = True
|
||||
ThreadingHTTPServer.request_queue_size = 256
|
||||
print(f'fake llm on :{PORT} mode={MODE} median={MEDIAN}', flush=True)
|
||||
ThreadingHTTPServer(('127.0.0.1', PORT), H).serve_forever()
|
||||
128
tools/loadtest/loadtest.py
Normal file
128
tools/loadtest/loadtest.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Load test: N students ask the assistant at the same moment (stream=true, exactly like the
|
||||
frontend), while B other students keep browsing the app (home, quiz, summary, quota).
|
||||
|
||||
usage: python loadtest.py BASE_URL users.json N_ASKERS N_BROWSERS ROUND OUT.json [--stagger S]
|
||||
users.json: output of mkusers.py; the first N_ASKERS users ask, the next N_BROWSERS browse.
|
||||
ROUND: which of each user's prepared questions to ask (0..2) — distinct text, so no answer-cache hits.
|
||||
Timing per ask: t_headers (HTTP status line), t_first (first `chunk` event), t_done (`done` event).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
BASE, USERS, N, B, ROUND, OUT = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]), sys.argv[6]
|
||||
WRITES = '--writes' in sys.argv
|
||||
STAGGER = float(sys.argv[sys.argv.index('--stagger') + 1]) if '--stagger' in sys.argv else 0.0
|
||||
users = json.load(open(USERS))
|
||||
askers, browsers = users[:N], users[N:N + B]
|
||||
T0 = None
|
||||
stop = asyncio.Event()
|
||||
|
||||
|
||||
async def ask(client, u, idx):
|
||||
await asyncio.sleep(idx * STAGGER)
|
||||
a = u['asks'][ROUND]
|
||||
rec = {'userId': u['userId'], 'grade': u['grade'], 'start': time.monotonic() - T0}
|
||||
t = time.monotonic()
|
||||
try:
|
||||
async with client.stream('POST', f'{BASE}/api/ai/ask', json={
|
||||
'userQuestion': a['question'], 'lessonId': str(a['lessonId']), 'stream': True,
|
||||
}, headers={'Authorization': f"Bearer {u['token']}"}, timeout=httpx.Timeout(180, connect=30)) as r:
|
||||
rec['status'] = r.status_code
|
||||
rec['t_headers'] = time.monotonic() - t
|
||||
if r.status_code != 200:
|
||||
rec['body'] = (await r.aread()).decode(errors='replace')[:300]
|
||||
return rec
|
||||
buf, nchunks = '', 0
|
||||
async for text in r.aiter_text():
|
||||
buf += text
|
||||
while '\n\n' in buf:
|
||||
part, buf = buf.split('\n\n', 1)
|
||||
ev = next((l[7:] for l in part.split('\n') if l.startswith('event: ')), '')
|
||||
if ev == 'chunk':
|
||||
nchunks += 1
|
||||
rec.setdefault('t_first', time.monotonic() - t)
|
||||
elif ev == 'done':
|
||||
rec['t_done'] = time.monotonic() - t
|
||||
d = json.loads(next(l[6:] for l in part.split('\n') if l.startswith('data: ')))
|
||||
rec['chunksUsed'] = len(d.get('usedChunks') or [])
|
||||
rec['messageId'] = d.get('messageId')
|
||||
elif ev == 'error':
|
||||
rec['error'] = part[-200:]
|
||||
rec['nchunks'] = nchunks
|
||||
rec['t_end'] = time.monotonic() - t
|
||||
except Exception as exc: # recorded, never hidden: a timeout is a result here
|
||||
rec['exc'] = f'{type(exc).__name__}: {exc}'[:200]
|
||||
rec['t_end'] = time.monotonic() - t
|
||||
return rec
|
||||
|
||||
|
||||
async def browse(client, u):
|
||||
recs = []
|
||||
paths = ['/api/home', f"/api/nodes/{u['nodeId']}/quiz", '/api/user/summary', '/api/ai/quota']
|
||||
if WRITES:
|
||||
paths.append('answer')
|
||||
quiz = None
|
||||
i = 0
|
||||
while not stop.is_set():
|
||||
p = paths[i % len(paths)]
|
||||
i += 1
|
||||
t = time.monotonic()
|
||||
try:
|
||||
if p == 'answer':
|
||||
q = quiz['questions'][i % len(quiz['questions'])]
|
||||
r = await client.post(f"{BASE}/api/questions/{q['id']}/answer", json={'selectedOptionId': int(q['options'][0]['id'])},
|
||||
headers={'Authorization': f"Bearer {u['token']}"}, timeout=60)
|
||||
p = '/x/answer'
|
||||
else:
|
||||
r = await client.get(f'{BASE}{p}', headers={'Authorization': f"Bearer {u['token']}"}, timeout=60)
|
||||
if '/quiz' in p and r.status_code == 200:
|
||||
quiz = r.json()
|
||||
recs.append({'path': p.split('/')[2], 'status': r.status_code, 'dt': time.monotonic() - t, 'at': t - T0})
|
||||
except Exception as exc:
|
||||
recs.append({'path': p.split('/')[2], 'status': 0, 'exc': type(exc).__name__, 'dt': time.monotonic() - t, 'at': t - T0})
|
||||
await asyncio.sleep(1.0)
|
||||
return recs
|
||||
|
||||
|
||||
async def main():
|
||||
global T0
|
||||
limits = httpx.Limits(max_connections=200, max_keepalive_connections=200)
|
||||
async with httpx.AsyncClient(limits=limits, http2=False) as client:
|
||||
T0 = time.monotonic()
|
||||
btasks = [asyncio.create_task(browse(client, u)) for u in browsers]
|
||||
await asyncio.sleep(3 if browsers else 0) # baseline browsing before the burst
|
||||
burst_at = time.monotonic() - T0
|
||||
results = await asyncio.gather(*(ask(client, u, i) for i, u in enumerate(askers)))
|
||||
burst_end = time.monotonic() - T0
|
||||
await asyncio.sleep(2 if browsers else 0)
|
||||
stop.set()
|
||||
brecs = [r for t in btasks for r in await t]
|
||||
json.dump({'base': BASE, 'n': N, 'browsers': B, 'round': ROUND, 'stagger': STAGGER,
|
||||
'burst_at': burst_at, 'burst_end': burst_end, 'asks': results, 'browse': brecs},
|
||||
open(OUT, 'w'), ensure_ascii=False, indent=1)
|
||||
ok = [r for r in results if r.get('t_done')]
|
||||
td = sorted(r['t_done'] for r in ok)
|
||||
tf = sorted(r['t_first'] for r in ok if r.get('t_first'))
|
||||
q = lambda a, p: a[min(len(a) - 1, int(p * len(a)))] if a else None
|
||||
print(f'N={N} ok={len(ok)}/{N} done p50={q(td, .5) and round(q(td, .5), 1)} p90={q(td, .9) and round(q(td, .9), 1)} '
|
||||
f'max={td and round(td[-1], 1)} first p50={q(tf, .5) and round(q(tf, .5), 1)}')
|
||||
bad = [r for r in results if not r.get('t_done')]
|
||||
for r in bad:
|
||||
print(' FAIL', {k: r.get(k) for k in ('userId', 'status', 'body', 'error', 'exc', 't_end')})
|
||||
if brecs:
|
||||
during = sorted(r['dt'] for r in brecs if burst_at <= r['at'] <= burst_end)
|
||||
before = sorted(r['dt'] for r in brecs if r['at'] < burst_at)
|
||||
errs = [r for r in brecs if r['status'] != 200]
|
||||
w = sorted(r['dt'] for r in brecs if r['path'] == 'answer' and burst_at <= r['at'] <= burst_end)
|
||||
if w:
|
||||
print(f' writes during: n={len(w)} p50={round(q(w, .5) * 1000)}ms max={round(w[-1] * 1000)}ms statuses={sorted(set(r["status"] for r in brecs if r["path"] == "answer"))}')
|
||||
print(f' browse before p50={q(before, .5) and round(q(before, .5) * 1000)}ms | during p50={q(during, .5) and round(q(during, .5) * 1000)}ms '
|
||||
f'p90={q(during, .9) and round(q(during, .9) * 1000)}ms max={during and round(during[-1] * 1000)}ms n={len(during)} errors={len(errs)}')
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
30
tools/loadtest/mkusers.py
Normal file
30
tools/loadtest/mkusers.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Run with: manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" env: LT_N, LT_PREFIX, LT_SEED
|
||||
# Creates N non-guest test users (fake 0900… numbers, no signals, no SMS) and prints
|
||||
# [{userId, token, grade, asks:[{lessonId, question}], nodeId, questionIds}] as JSON.
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from content.models import Lesson, Question
|
||||
|
||||
N = int(os.environ['LT_N'])
|
||||
PREFIX = os.environ.get('LT_PREFIX', '0900990')
|
||||
GRADES = [8, 9, 10, 11, 12, 4, 5, 6, 7, 3]
|
||||
rnd = random.Random(int(os.environ.get('LT_SEED', '7')))
|
||||
|
||||
out = []
|
||||
for i in range(N):
|
||||
grade = GRADES[i % len(GRADES)]
|
||||
phone = f'{PREFIX}{i:04d}'
|
||||
user, _ = User.objects.get_or_create(phone=phone, defaults={'is_guest': False, 'name': f'تست بار {i}', 'grade_id': grade})
|
||||
qs = list(Question.objects.filter(lesson__node__unit__grade_id=grade).select_related('lesson__node')
|
||||
.values_list('id', 'prompt', 'lesson_id', 'lesson__node_id')[:4000])
|
||||
picks = rnd.sample(qs, 6)
|
||||
out.append({
|
||||
'userId': user.id, 'token': issue_access_token(user.id), 'grade': grade,
|
||||
'asks': [{'lessonId': p[2], 'question': f'این سوال رو قدم به قدم توضیح بده: {p[1][:300]}'} for p in picks[:3]],
|
||||
'nodeId': picks[3][3], 'answerQuestionIds': [p[0] for p in picks[3:]],
|
||||
})
|
||||
print('LTJSON' + json.dumps(out, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user