Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login

- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker;
  beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a
  burst of questions (3-40 s each) took every thread and the map and quizzes queued behind.
  The slot is held until the stream is actually finished or closed.
- Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked
  get_or_create for node progress and streak, and today's streak recorded once per day
  via the cache instead of a transaction per answer.
- /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list
  of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract,
  HomeMatchesItsSerializerTests keeps them equal).
- otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is
  bounded per code and per phone.
- /api/health aiConcurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:55:24 +03:30
parent feaa5eb540
commit 2c708088e4
11 changed files with 279 additions and 19 deletions

76
ai_tutor/concurrency.py Normal file
View File

@@ -0,0 +1,76 @@
"""
سقفِ جواب‌های هم‌زمانِ دستیار در هر worker (AI_MAX_CONCURRENT_PER_WORKER).
جوابِ دستیار یک نخِ gunicorn را تا پایانِ کار (۳ تا ۴۰ ثانیه، منتظرِ Qdrant و مدل) نگه می‌دارد. بدونِ
سقف، اگر چند ده دانش‌آموز با هم سوال بپرسند همه‌ی نخ‌های worker گرفته می‌شوند و نقشه، آزمون و ثبتِ
جواب (که هرکدام چند میلی‌ثانیه‌اند) پشتِ آن‌ها صف می‌کشند: یک بخشِ کند کلِ اپ را می‌خواباند. این همان
الگوی bulkhead است: هر بخش سهمِ جدای خودش را دارد. درخواستی که جا نگیرد فوراً ۵۰۳ می‌گیرد.
سمافور مالِ همین پروسه است (هر worker جدا). هم‌زمانیِ واقعی زیرِ بارِ gunicorn با مدلِ واقعی آزموده نشده؛
تست‌ها رفتارِ گرفتن/پس‌دادن را در مسیرِ JSON و استریم نگه می‌دارند (ai_tutor/tests_concurrency.py).
"""
import threading
from django.conf import settings
_lock = threading.Lock()
_in_flight = 0
def limit() -> int:
return settings.AI_MAX_CONCURRENT_PER_WORKER
def try_acquire() -> bool:
"""بی‌انتظار: یا جا هست و گرفته می‌شود، یا False. انتظار نخ را همان‌طور نگه می‌داشت که می‌خواهیم نشود."""
global _in_flight
with _lock:
if _in_flight >= limit():
return False
_in_flight += 1
return True
def release() -> None:
global _in_flight
with _lock:
if _in_flight <= 0:
# پس‌دادنِ بی‌گرفتن یعنی باگ در جفت‌شدنِ گرفتن/پس‌دادن؛ شمارنده منفی نمی‌شود تا سقف بی‌اثر نشود.
raise RuntimeError('ai concurrency slot released more times than acquired')
_in_flight -= 1
def in_flight() -> int:
return _in_flight
class ReleaseWhenDone:
"""
محتوای استریم را می‌پیچد تا جا وقتی پس داده شود که استریم واقعاً تمام شد: یا با پایانِ خواندن،
یا با close() که جنگو هنگامِ بستنِ پاسخ صدا می‌زند (کاربر وسطِ جواب صفحه را بست، یا پاسخ اصلاً
خوانده نشد). generatorی که شروع نشده با close() بلوکِ finallyاش را اجرا نمی‌کند؛ برای همین
پس‌دادن این‌جاست، نه داخلِ generator.
"""
def __init__(self, iterable):
self._iterable = iterable
self._released = False
def _release_once(self):
if not self._released:
self._released = True
release()
def __iter__(self):
try:
yield from self._iterable
finally:
self._release_once()
def close(self):
try:
close = getattr(self._iterable, 'close', None)
if close is not None:
close()
finally:
self._release_once()