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:
76
ai_tutor/concurrency.py
Normal file
76
ai_tutor/concurrency.py
Normal 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()
|
||||
Reference in New Issue
Block a user